c++ 类型擦除

  1. 什么是类型擦除?

类型擦除就是将原有类型消除或者隐藏。

为什么要类型擦除?

因为很多时候并不需要关心具体类型是什么或者根本就不需要这个类型,通过类型擦除可以获取很多好处,例如更好的扩展性、消除耦合以及一些重复行为,使程序更加简洁高效。

c++中类型擦除方式主要有以下五种:

  • 多态
  • 模板
  • 容器
  • 通用类型
  • 闭包

通过多态擦除类型

该方式最简单也是经常用的,将派生类型隐式转换成基类型,再通过基类去多态的调用行为。

在这种情况下,不用关心派生类的具体类型,只需要以统一的方式去做不同的事情,所以就把派生类型转成基类型隐藏起来,这样不仅仅可以多态调用还使程序具有良好的可扩展性。

然而这种方式的类型擦除仅仅是部分的类型擦除,因为基类型仍然存在,而且这种类型擦除的方式必须是继承的方式,继承使得两个对象强烈的耦合在一起。所以通过多态来擦除类型的方式有较多局限性。

通过模板擦除类型

该方式本质上是对不同类型的共同行为进行抽象,不同类型彼此之间不需要通过继承这种强耦合的方式去获得共同的行为。仅通过模板就能获取共同行为,降低不同类型之间的耦合,是一种很好的类型擦除方式。

然而,虽然降低了对象间的耦合,但是基本类型始终需要指定,并没有消除基本类型。

通过容器擦除类型

boost.variant 可以把各种类型包起来,从而获得一种统一的类型,而且不同类型的对象间没有耦合关系,它仅仅是一个类型的容器。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct blob
{
const char *pBuf;
int size;
};

//定义通用的类型,这个类型可能容纳多种类型
typedef boost::variant<double, int, uint32_t, sqlite3_int64, char*, blob, NullType>Value;

vector<Value> vt; //通用类型的容器,这个容器现在就可以容纳上面的那些类型的对象了
vt.push_back(1);
vt.push_back("test");
vt.push_back(1.22);
vt.push_back({"test", 4});

示例中擦除了不同类型,使得不同的类型都可以放到一个容器中。取值的时候,通过get(Value)就可以获取对应类型的值。

这种方式是通过某种容器把类型包起来,从而达到类型擦除的目的。缺点是这个通用的类型必须事先定义好,只能容纳声明的类型,增加一种新类型就不行了。

通过通用类型擦除类型

该方式可以消除容器擦除类型的缺点,类似于c#和java中的object类型。这种通用类型是通过boost.any实现的,它不需要预先定义类型,不同类型都可以转成any。示例:

1
2
3
4
5
unordered_map<string, boost::any> m_creatorMap;
m_creatorMap.insert(make_pair(strKey, new T)); //T may be any type

boost::any obj = m_creatorMap[strKey];
T t = boost::any_cast<T>(obj);

但是还存一个缺点,就是取值的时候仍然依赖于具体类型,无论是通过get还是any_case,都需要T的具体类型,这在某种情况下仍然有局限性。

通过闭包擦除类型

闭包可以称为匿名函数或lamda表达式,c++11中的lamda表达式就是c++中的闭包。

c++11引入lamda,实际上引入了函数式编程的概念,函数式编程有很多优点,使代码更简洁,而且声明式的编码方式更贴近人的思维方式。函数式编程在更高的层次上对不同类型的公共行为进行了抽象,从而使我们不必去关心具体类型。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
std::map<int, std::function <void()>> m_freeMap; //保存返回出去的内存块

template<typename R, typename T>
R GetResult()
{
R result = GetTable<R, T>();

m_freeMap.insert(std::make_pair(result.sequenceId, [this, result]
{
FreeResult(result);
}));
}

bool FreeResultById(int& memId)
{
auto it = m_freeMap.find(memId);
if (it == m_freeMap.end())
return false;

it->second(); //delete by lamda
m_freeMap.erase(memId);

return true;
}

总结

name: github-action-email
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:

# 检出代码
  • name: CheckoutRepo
    uses: actions/checkout@v2
# 获取master分支上最新一条提交的git log  
  • name: GetLastLog
    id: git_log
    uses: Edisonboy/latest-git-log-action@main
    with:
    tag: origin/master
# 发送邮件    
  • name: Send email
    uses: dawidd6/action-send-mail@v3
    with:
    server_address: smtp.qq.com
    server_port: 465
    username: $
    password: $
    subject: Github Actions job result
    to: $
    from: $
    body: $ push log : $通过闭包去擦除类型,可以解决前面四种擦除方式遇到的问题,优雅而简单!

本文章尝试以实现一个任务类型为例,讲述一些使用C++编程时的思考。本文的前置知识是面向对象编程。

动机

最近,笔者在写一个线程池,其中的每个线程都有一个与其相关联的任务队列,队列存放待运行的任务。此处的“任务”,指的是**函数对象**,它可以用括号运算符进行函数调用。函数对象主要分为函数指针、重载了括号运算符的类和lambda:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 假设具体函数的调用都返回void、没有参数

// 函数指针
void foo1() {
std::cout << "foo1";
}
void(*fp)() = &foo1;
fp(); // 输出"foo1"

// 重载了括号运算符的类
struct foo2 {
void operator()() {
std::cout << "foo2";
}
};
foo2 f2;
f2(); // 输出"foo2"

// Lambda
auto foo3 = []() { std::cout << "foo3"; };
foo3(); // 输出"foo3"

事实上,lambda的实现,就是一个编译器生成的匿名类,通常以一个编译器可识别的mangled name来命名。这个类会重载括号运算符以实现类似于函数的行为。见**lambda implementation**

几个尝试

为了通用性,这些任务应可以是不同类型的,但是C++又要求在编译时确定对象具体类型以进行内存布局(队列类型在编译时确定),这可怎么办?

先来看如何支持函数指针。首先需要确定函数的签名。比如,void()这种函数签名对应的函数指针类型是void(*)()。假设选择了它,那么任务队列就可以写成:

1
2
3
4
class my_thread {
using task_type = void(*)();
my_queue<task_type> task_queue;
};

而要支持用户自定义函数对象类,一般就会通过继承确定类的接口,让任务对象在运行时多态。可能的实现是,定义一个task_base类,其定义了任务对象的公共接口,然后编写task_base的子类实现具体任务,并重载括号运算符。

任务队列的类型是my_queue>,用基类指针去管理任务对象。这不失为一个直截了当的方法,并且比较容易实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 假设具体的任务函数体的调用签名都是void

struct task_base {
virtual ~task_base() = 0;
virtual void run() const = 0;
};

// 用户编写的具体任务类
struct task_impl : public task_base {
void run() const override {
// 运算...
}
};

// 使用:
std::unique_ptr<task_base> pt = std::make_unique<task_impl>();
pt->run();

这个方法的负面之处是非常缺乏伸缩性。首先,编写子类的责任被推给了用户,可能一个不太复杂的函数调用会被强加上任务基类task_base的包装;而且用起来也不方便。上面两行使用代码发生了什么?第一步是手动的堆内存分配,然后是一个向上转型(task_impl转为基类task_base)。

要想支持lambda?

1
2
3
4
5
auto lambda0 = []() { std::cout << "lambda0"; };
auto lambda1 = []() { std::cout << "lambda1"; };
auto lambda2 = []() { std::cout << "lambda2"; };

my_queue<???> task_queue;

这三个lambda看起来极其相似,但是它的类型各不相同。因此,虽然有一样的接口void(),但不能直接存放到同一个容器中去。如果通过**[std::is_same](https://link.zhihu.com/?target=https%3A//en.cppreference.com/w/cpp/types/is_same)**检查以上任意两个lambda类型是否一致,都会得到false:

1
2
3
4
5
6
// decltype说明符可以返回一个标识符或表达式的类型
using type0 = decltype(lambda0);
using type1 = decltype(lambda1);
using type2 = decltype(lambda2);
static_assert(std::is_same<type0, type1>::value == false);
static_assert(std::is_same<type1, type2>::value == false);

std::is_same的value成员是一个编译期进行运算并得出结果的常量,当两个类型模板参数相等时为true。**[static_assert](https://link.zhihu.com/?target=https%3A//en.cppreference.com/w/cpp/language/static_assert)**用于编译时判断表达式是否为真。

我们能拥有这样的API吗:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct my_task {
template <typename F>
my_task(F&& f); // 模板化的构造函数,
// 从任意类型的函数对象构建任务

void operator()() const; // 没有显式的虚函数调用,
// 不用指针操作而用值语义
};

// 使用:
my_task t{ /* 任意的函数对象 */ }; // 用户不用填写函数对象的具体类型
// 由编译器推导
t();

类型擦除(Type Erasure)

答案是可以的!在STL标准库中,这类设施典型的代表就是std::function,它通过类型擦除的技巧,不必麻烦用户编写继承相关代码,并能包装任意的函数对象。

C++语境下的类型擦除,技术上来说,是编写一个类,它提供模板的构造函数和非虚函数接口提供功能;隐藏了对象的具体类型,但保留其行为。简单地说,就是库作者把面向对象的代码写了,而不是推给用户写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct task_base {
virtual ~task_base() {}
virtual void operator()() const = 0;
};

template <typename F>
struct task_model : public task_base {
F functor_;

template <typename U> // 构造函数是函数模板
task_model(U&& f) :
functor_(std::forward<U>(f)) {}

void operator()() const override {
functor_();
}
};

首先,抽象基类task_base作为公共接口不变;其子类task_model(角色同上文中的task_impl)写成类模板的形式,其把一个任意类型F的函数对象function_作为数据成员。

子类写成类模板的具体用意是,对于用户提供的一个任意的类型F,F不需要知道task_base及其继承体系,而只进行语法上的duck typing检查。 这种方法避免了继承带来的侵入式设计。换句话说,只要能合乎语法地对F调用预先定义的接口,代码就可以编译,这个技巧就能运作。此例中,预先定义的接口是void(),以functor_();的形式调用。

然后,我们把它包装起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class my_task {
std::unique_ptr<task_base> ptr_;

public:
template <typename F>
my_task(F&& f) {
using model_type = task_model<F>;
ptr_ = std::make_unique<model_type>(std::forward<F>(f));
}

void operator()() const {
ptr_->operator()();
}

// 其他部分略
};

如果对于类模板、函数模板感到迷惑,可以看下一节的简单解释。

上文尝试从技术上定义类型擦除,不过是与其他语境做一个区别。看了代码以后,我们尝试引出更广义的类型擦除的语义。

首先,初始动机是用一个类型包装不同的函数对象。然后,考虑这些函数对象需要提供的功能(affordance),此处为使用括号运算符进行函数调用。最后,把这个功能抽取为一个接口,此处为my_task,我们在在这一步擦除了对象具体的类型。这便是类型擦除的本质:切割类型与其行为,使得不同的类型能用同一个接口提供功能。

对my_task进行简单测试的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 普通函数
void foo() {
std::cout << "type erasure 1";
}
my_task t1{ &foo };
t1(); // 输出"type erasure 1"

// 重载括号运算符的类
struct foo2 {
void operator()() {
std::cout << "type erasure 2";
}
};
my_task t2{ foo2{} };
t2(); // 输出"type erasure 2"

// Lambda
my_task t3{
[](){ std::cout << "type erasure 3"; }
};
t3(); // 输出"type erasure 3"

接口简洁,类型参数全部由编译器推断,实现了上文中的需求。

模板

此处插叙模板代码的相关知识。编译器不能直接用模板代码生成汇编码,而是先根据用户提供的(或编译器根据代码上下文推断出的)模板参数,实例化(instantiation)出一个具体的类型(type)或函数定义(function definition),最后才能编译生成的类型。试考虑std::vector:

1
2
3
4
5
6
7
8
// 这是一个类模板,而非一个真正的类
template <typename T>
class vector {
/* ... */
};

std::vector<int> vec_int; // 生成了类std::vector<int>
std::vector<char> vec_char; // 生成了类std::vector<char>

变量vec_int的声明会导致编译器根据类模板实例化一个类(class), std::vector,它是一个具体的类型(type),因此编译器能够直接用它的代码来生成汇编码。

同时,这句声明调用了std::vector的默认构造函数,因此编译器也会生成这个函数的定义。换句话说,对于一个类模板生成的类,只有用户实际用到的该类的成员函数,编译器才会生成其定义。

而vec_char的声明会生成另一个类,std::vector, 它与std::vector既非同一个类(class),亦非同一个类型(type)。

回忆上文中三类函数对象:函数指针、重载括号运算符的类、lambda。以下代码展示了以它们作为类型参数,并实例化task_model生成的代码的大概面貌:

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
template <typename F> 
struct task_model;

// 针对签名为void()的函数指针
void foo1();
task_model<decltype(&foo1)> t1{ &foo1 };

// 针对用户编写的类foo2
struct foo2;
task_model<foo2> t2{ foo2{} };

// lambda
auto foo3 = []() {};
task_model<decltype(foo3)> t3{ foo3 };

// task_model分别实例化生成的类的伪代码:
task_model<void(*)()> {
void(*functor_)();
};
task_model<foo2> {
foo2 functor_;
};
// lambda具体的类名由编译器指定
task_model<foo3_type> {
foo3_type functor_;
};

陈词滥调(boilerplate)

编写一个类型擦除类,当然也离不开编写它的构造函数、赋值函数和析构函数,这是C++特色的“陈词滥调”,而其中大部分纠结都可归为资源所有权的问题:一个对象是否拥有(own)其资源的生命周期;如果拥有,是唯一还是共享地拥有?支持复制,还是只支持移动?

my_task有一个std::unique_ptr数据成员,这个智能指针拥有具体任务对象的生命周期;而一个任务也没有创造副本的需求。因此,my_task不需支持复制,仅支持移动构造或赋值。

下面展示my_task的相关函数:

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
class my_task {
public:
// 构造函数
template <typename F>
my_task(F&& f) {
using model_type = task_model<F>;
ptr_ = std::make_unique<model_type>(std::forward<F>(f));
}

// 移动构造函数
my_task(my_task&& oth) noexcept :
ptr_(std::move(oth.ptr_))
{}

// 移动赋值函数
my_task& operator=(my_task&& rhs) noexcept {
ptr_ = std::move(rhs.ptr_);
return *this;
}

// 析构函数
~my_task() = default;

// 删除复制构造函数、复制赋值函数
my_task(const my_task&) = delete;
my_task& operator=(const my_task&) = delete;
};

析构函数的责任是正确地释放资源。my_task使用了智能指针,智能指针会正确地调用delete,所以使用编译器合成的默认析构函数即可。

task_model的析构函数会递归调用F的析构函数,这依赖于用户正确编写了F的析构函数,因此task_model也用编译器合成的默认析构函数。

至此,my_task的编写便基本完成了!下一篇文章中,会继续深入讨论类型擦除的话题。