浅析C++的几种类型擦除实现
简单介绍了类型擦除的应用场景,以及基于void*指针、基于继承多态或模板实现的类型擦除
浅析C++的几种类型擦除实现
一、关于类型擦除
- 类型擦除(Type Erasure)和模板一样主要用于泛型编程,其目的是写出能接受任意类型参数的函数或类(处理不同的类对象而无需知晓其类型),只要它们提供了所需要的方法(无需关注该方法的具体实现),其常用于以下场景
- 实现泛型容器
- 例如想实现一种可以存储不同类型的通用对象池容器(此处指的不是单个池内存储不同类型的对象,而是可以用一份代码得到多种类型的对象池),就需要类型擦除
- 隐藏具体类型信息
- 某些情况下不想暴露对象的的具体类型,而只想提供一个通用的接口供用户使用,这时类型擦除可以帮助你隐藏具体类型信息,将类型细节封装在内部
- 统一不同类型接口
- 例如C++11由模板等实现了类型擦除的
std::tuple
和std::function
,后者可接收函数对象(仿函数)、函数指针、Lambda等多种不同的可调用对象,但它却可通过统一的接口来调用这些对象,而无需关心具体的是什么类型
- 例如C++11由模板等实现了类型擦除的
- 实现泛型容器
1
2
template <typename R, typename... Args>
class function<R(Args...)>;
二、基于void*
指针
void*
类型的对象是一个固定大小的指针- 它可以指向任何类型的对象或转换到任何类型(而无需知道指向对象的具体类型),这就完成了类型信息的擦除
- 在对该类型的指针进行实际操作前,通常需将其转换回其原始类型,否则无法调用所其指向对象所提供的方法
- 库函数
qsort
不同于STL的std::sort
使用模板与多种优化策略(参考该博客)实现,其未借助模板,而是使用void*
类型擦除实现了可对任何类型的数组进行排序的纯快速排序- 参数
base
:void*
类型指针,表示待排序数组 - 参数
nmemb
:数组中的元素总个数 - 参数
size
:数组中每个元素占用的内存大小 - 参数
compare
:用于比较数组元素的函数指针(接收两个void*
参数)
- 参数
1
2
void qsort(void* base, size_t nmemb, size_t size,
int(*compare)(const void*, const void*));
- 当需对整数数组进行排序时,除了基本信息外只需提供一个针对整数类型的比较函数即可
1
2
3
4
5
6
7
8
9
10
11
12
int CompareInt(const void* lhs, const void* rhs)
{
//将void*强转回int进行比较
return *(const int*)lhs - *(const int*)rhs;
}
int main()
{
int arr[8] = { 1, 8, 4, 7, 6, 2, 3, 9 };
//在qsort()函数需要知道如何比较两元素的大小时,才需知道元素的具体类型是什么(此前都不需要知道),此时才委托传入的比较函数提供该类型的大小特性信息
qsort(arr, sizeof(arr) / sizeof(arr[0]), sizeof(arr[0]), CompareInt);
}
- 函数
qsort
弊端较多,属于较为古老的C实现,在C++中还有其他更好的实现方法- 隐藏了类型信息,可读性极差
- 无类型检查,类型不安全
- 可能导致无法在编译时发现错误,难以调试
- 对其误操作(如错误的类型转换)可能引发严重运行时错误
三、基于继承与多态
3.1 虚函数的动态多态
- 对于继承自同一基类(一般是抽象基类)的多个派生类,多态本身就是对他们的类型擦除,使用时只需要调用顶层提供的虚函数即可,无需知晓具体是什么派生类,其缺点如下
- 运行性能有损耗,因为动态多态中间的
vptr
需跳转一层vtable
- 类型擦除不完全,必须先知道基类类型,才能擦除派生类型
- 运行性能有损耗,因为动态多态中间的
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
#include <iostream>
#include <vector>
struct Shape
{
virtual void Draw() const {}
virtual ~Shape() = default;
};
struct Circle :public Shape
{
void Draw() const override { std::cout << "Circle\n"; }
};
struct Square :public Shape
{
void Draw() const override { std::cout << "Square\n"; }
};
struct Triangle :public Shape
{
void Draw() const override { std::cout << "Triangle\n"; }
};
//批量绘制几何体
void DrawShapes(std::vector<Shape*> _shapes)
{
//绘制传入数组内的所有几何体
for (Shape* _shape : _shapes)
_shape->Draw();
}
int main()
{
//装箱一组几何体对象
Shape* circle = new Circle();
Shape* square = new Square();
Shape* triangle = new Triangle();
std::vector<Shape*> shapes = { circle, square, triangle };
//一口气绘制全部
DrawShapes(shapes);
//Circle
//Square
//Triangle
delete circle;
delete square;
delete triangle;
}
3.2 CRTP的静态多态
3.2.1 基本实现思路
- 元编程(Metaprogramming)技巧,其核心思想是让程序在编译期间完成计算、类型推导或代码生成,从而提升运行时效率(静态比动态多态更快)或增强代码的灵活性
- 奇异递归模板模式(CRTP, Curiously Recurring Template Pattern)使用了元编程技巧
- 派生类
Derived
将自身类型作为模板参数传递给模板基类Base<T>
,自己继承自这个实例化的模板类Base<Derived>
,使其能够了解派生类信息 - 接收派生类模板参数的模板类在编译期完成实例化,无需借助虚函数,故无需在运行时通过虚表进行动态调用,而是直接调用派生类的普通函数(缺点就是模板会拉长编译时间,并增大代码体积,且若多层继承则实现复杂难以调试)
- 派生类
- CRTP实现的是模板基类内的类型擦除,目的是让基类中能够直接访问派生类而无需关注派生类的具体类型,这实现了模板类成员方法的静态多态,弥补了模板类不能包含虚函数成员的限制
3.2.2 编译期静态多态
- 可以在基类中定义一个函数接口(提供默认实现或空实现),在派生类中提供同名函数实现,这会直接覆盖基类中的同名函数(若不提供,则保留基类实现)
- 在基类内部的另一个函数中,可以通过直接将
this
指针强转为传入派生类型的指针,用其调用该名称函数,调用的版本就会是派生类的版本而不是基类的版本(除非子类保留了基类版本)
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
#include <iostream>
template <typename T>
class Base
{
public:
void Interface()
{
//将Base*指针强转为模板参数类型的指针T*
static_cast<T*>(this)->Implementation();
}
void Implementation() { std::cout << "Implementation Base\n"; }
};
//提供同名的普通Implementation函数覆盖重写(而不是虚函数重写)
class DerivedA :public Base<DerivedA>
{
public:
void Implementation() { std::cout << "Implementation DerivedA\n"; }
};
//提供同名的普通Implementation函数覆盖重写(而不是虚函数重写)
class DerivedB :public Base<DerivedB>
{
public:
void Implementation() { std::cout << "Implementation DerivedB\n"; }
};
//不提供Implementation的覆盖,则保留基类中的Implementation版本
class DerivedC :public Base<DerivedC> {};
int main()
{
DerivedA a;
a.Interface();
//Implementation DerivedA
DerivedB b;
b.Interface();
//Implementation DerivedB
DerivedC c;
c.Interface();
//Implementation Base
}
- 在上述实现中,若不想以
public
暴露派生类的Implementation
实现,则可通过友元将自身私有成员唯一暴露给基类的以自身实例化的类版本Base<Derived>
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
#include <iostream>
template <typename T>
class Base
{
public:
void Interface()
{
//由于派生类声明了友元,故可调用private的Implementation()
static_cast<T*>(this)->Implementation();
}
void Implementation() { std::cout << "Implementation Base\n"; }
};
class DerivedA :public Base<DerivedA>
{
//友元提供权限
friend class Base<DerivedA>;
private:
void Implementation() { std::cout << "Implementation DerivedA\n"; }
};
class DerivedB :public Base<DerivedB>
{
//友元提供权限
friend class Base<DerivedB>;
private:
void Implementation() { std::cout << "Implementation DerivedB\n"; }
};
class DerivedC :public Base<DerivedC> {};
int main()
{
DerivedA a;
a.Interface();
//Implementation DerivedA
DerivedB b;
b.Interface();
//Implementation DerivedB
DerivedC c;
c.Interface();
//Implementation Base
}
3.2.3 以可继承单例为例
- 我们在学习创建型设计模式时,实现过一种可继承的单例模式基类,其采用的就是CRTP方法,但此处的实现目的仅仅是利用继承减少重复代码,而并未使用到CRTP的编译时多态
SingletonEager.hpp
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
#ifndef _SINGLETON_EAGER_HPP_
#define _SINGLETON_EAGER_HPP_
template <typename T>
class Manager
{
protected:
static T* manager;
public:
static T* GetInstance();
protected:
Manager() = default;
~Manager() = default;
Manager(const Manager&) = delete;
Manager& operator=(const Manager&) = delete;
};
//在程序启动时就初始化,这是饿汉式实现
template <typename T>
T* Manager<T>::manager = new T();
template <typename T>
T* Manager<T>::GetInstance()
{
//直接返回在程序刚开始时就初始化好的manager
return manager;
}
#endif
GameManager.hpp
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
#ifndef _GAME_MANAGER_HPP_
#define _GAME_MANAGER_HPP_
#include <iostream>
//采用饿汉式实现
#include "SingletonEager.hpp"
//游戏主管理器
class GameManager : public Manager<GameManager>
{
//授予Manager基类的GetInstance函数权限以调用GameManager类的构造函数
friend class Manager<GameManager>;
public:
int Run();
protected:
GameManager() = default;
~GameManager() = default;
};
int GameManager::Run()
{
std::cout << "GamaManager Run Success\n";
return 0;
}
#endif
Test.cpp
1
2
3
4
5
6
7
8
9
#include "GameManager.hpp"
int main()
{
//运行游戏主管理器内的主循环方法,并获取返回值
//注意这里获取的实例是开辟在堆区的,但此处可以不用delete,系统会给你擦屁股(虽说最好处理一下,但是单例模式问题不大)
return GameManager::GetInstance()->Run();
//GamaManager Run Success
}
四、基于非继承模板
4.1 C++17的std::variant
- C++17引入了标准库组件
std::variant
和模板函数std::visit
(后者基于元编程)std::variant<存储类型1, 存储类型2, ...>
- 实现了类型安全的联合体,可同时存储多种类型的对象,所有存储的不同数据类型必须在编译时就确定,不支持动态类型
- C++98的
union
无类型检查(类型不安全),性能最高,容纳最大成员类型 - C++17的
std::variant
是编译时类型检查,性能中等,容纳最大成员类型 - C++17的
std::any
是运行时类型检查(类型安全),性能最差,动态容量
- C++98的
- 默认构造存储首个类型默认值,如
std::variant<int, float>
默认存int{0}
- 实现了类型安全的联合体,可同时存储多种类型的对象,所有存储的不同数据类型必须在编译时就确定,不支持动态类型
std::visit(可调用对象 访问方法, std::variant 访问对象)
- 基于访问者(Visitor)设计模式,解耦算法自身与访问对象(将原属于成员函数的方法转移到类外实现,对于不同的类型统一重载算法进行处理)
- 在编译时生成所有可能调用的算法重载,属于静态的编译时多态(对比虚函数动态的运行时多态,无需查阅虚表,运行速度更快)
- 在运行时类型安全地自动匹配
std::variant
当前存储的类型(比void*
更安全),调用对应重载函数
- 所以使用
std::variant
实现类型擦除的关键在于定义传给std::visit
的可访问对象,这个可访问对象发挥实现元编程的作用 - 方法一:通过静态多态的模板函数实现
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
#include <iostream>
#include <vector>
//支持std::variant和std::visit
#include <variant>
struct Circle
{
void Draw() const { std::cout << "Circle\n"; }
};
struct Square
{
void Draw() const { std::cout << "Square\n"; }
};
struct Triangle
{
void Draw() const { std::cout << "Triangle\n"; }
};
//定义联合体,可以是三种几何体之一
using Shape = std::variant<Circle, Square, Triangle>;
//一个泛型的可调用对象(仿函数)
struct GenericInvoker
{
//模板函数,且重载了小括号运算符成为仿函数
//可以接受任何的对象并调用其Draw函数
template<typename T>
void operator()(T& _shape) const
{
_shape.Draw();
}
};
//一个可以绘制多种几何体的函数
void DrawShapes(const std::vector<Shape>& _shapes)
{
//绘制传入数组内的所有几何体
for (const Shape& _shape : _shapes)
{
//传入可调用对象,以及具有多种类型可能的被访问对象
//std::visit在编译时生成所有可能的函数重载,以便在运行时静态调用
std::visit(GenericInvoker(), _shape);
}
}
int main()
{
//装箱一组几何体对象
std::vector<Shape> shapes{ Circle{}, Square{}, Triangle{} };
//一口气绘制全部
DrawShapes(shapes);
//Circle
//Square
//Triangle
}
- 方法二:通过C++11的
auto
关键字实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <string>
#include <variant>
int main()
{
//可存储int、float或std::string
std::variant<int, float, std::string> var;
//存储int
var = 42;
std::cout << std::get<int>(var) << "\n"; //42
//存储float
var = 3.14f;
std::cout << std::get<float>(var) << "\n"; //3.14
//存储string
var = "Hello";
std::cout << std::get<std::string>(var) << "\n"; //Hello
//传入可调用对象Lambda,以及具有三种可能存储类型的被观测对象var
std::visit([](auto&& arg) { std::cout << arg << "\n"; }, var);
//Hello
}
4.2 C++20的concept
约束
- C++20引入的
concept
(头文件<concepts>
)用于对模板参数进行类型约束- 传入的参数类型必须满足特定条件,否则会在编译时报错(学过CSharp的应该熟悉,这不就泛型约束嘛,虽然二者在使用上差别较大)
- 和CRTP一样都通过模板实现并都在编译期完成,但
concept
无需实现继承,且若发生错误,其报错信息比CRTP更清晰(暂未研究怎么个清晰法,遇到了再来补上)
- 下述例程核心是对模板函数
GenericPrint
的参数类型进行约束,只有满足Printable
约束的类对象才能被该函数使用,换句话说,只要类拥有Printable
所需的行为,就可被该函数调用,而无需知晓该类具体是什么,这也是一种类型擦除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <concepts>
//定义了名为Printable的Concept约束,通过requires()定义对类型T的约束
template<typename T>
concept Printable = requires(T a)
{
//检查类型T是否满足:拥有名为Print的成员函数、接收类型为std::ostream&的参数、返回类型是void
{ a.Print(std::cout) } -> std::same_as<void>;
};
//模板函数,接受一个const&类型的T参数
//<Printable T>限制该模板函数的参数类型T,必须满足先前定义的Printable约束
template<Printable T>
void GenericPrint(const T& t)
{
t.Print(std::cout);
}
本文由作者按照 CC BY-NC-SA 4.0 进行授权