C++类型擦除详解

60 阅读7分钟

Type Erasure enables using various concrete types through a single generic interface.

C++是强类型语言,每个变量在声明时就已经有了对应的类型,在不使用类继承的情况下,不同的类型直接是不兼容的。不同的类之间的转化其实就相当于底层的reinterpret_cast,直接改变底层二进制的解读形式,不安全。如果我们想要一个统一的接口,可以接受多种不同类型的对象。比如希望一个容器能同时存放不同类型的元素,希望一个函数能接收任意可调用对象或希望一个对象能在运行时保存任何类型的数据。

此时我们可以使用模板进行接口的编写,如下:

template <typename T>
void call(T t) { t(); }

这里的模板函数可以接受任意类型的参数 T,比如:

void foo() {}
struct Functor { void operator()() { std::cout << "Functor\n"; } };

但是实际上在传入这些类型时,会发生模板类型推导,为每种类型生成一个独立的函数重载,例如:

call<void(*)()>(void(*)())
call<Functor>(Functor)
call<lambda_xxx>(lambda_xxx)

此时其实就是单纯的函数重载,也被称为静态多态,每个不同的 T 对应一个不同的函数版本,编译器在编译阶段就决定了具体调用哪个版本。但是我们之前说过,我们的需求是需要一个完全统一的接口,上述模板函数在运行时,这些函数已经被编译成独立的机器码,互相之间没有统一的接口或抽象类型。所以如果我们有一堆不同类型的对象,是没有办法进行统一管理的,比如:

std::vector<???> funcs;
funcs.push_back([] { std::cout << "A\n"; });
funcs.push_back(func1());

此时我们想到了可以使用继承+虚函数的方式实现多态,此时就可以使用统一的基类指针去管理子类对象了。但此时就是侵入式设计了,要求所有需要统一管理的类型必须继承自同一个基类,事先设计好继承结构,但是很多类型不是继承体系的一部分。比如我们上面需要一个接口统一管理函数这类的可调用类型,其中函数指针,lambda表达式是没有办法使用继承的。

这时候,我们就需要一种既能在运行时统一管理不同类型对象,又不要求它们继承自同一基类的机制,这就是类型擦除。类型擦除的本质是:在编译期保存类型行为,在运行时抹去具体类型,只保留“能做什么”这一层抽象。换句话说,类型擦除让我们在编译期捕获类型的能力(比如能被调用、能被拷贝、能被销毁),然后在运行时把这些能力统一封装在一个“抽象接口”里,从而在运行时以统一的方式操作原本不同类型的对象。

下面给出C++标准库中std::function的示例:

void f() { std::cout << "function pointer\n"; }

int main() {
    std::function<void()> func;

    func = f;                    // 函数指针
    func();                      // -> function pointer

    func = [] { std::cout << "lambda\n"; }; // lambda 表达式
    func();                      // -> lambda

    struct Functor {
        void operator()() const { std::cout << "functor\n"; }
    };
    func = Functor{};             // 仿函数对象
    func();                       // -> functor
}

这里的 std::function<void()> 可以持有三种完全不同的类型:普通函数指针,lambda表达式,可调用对象。但是调用这三者的方式却完全一致--func()

所以使用类型擦除可以不修改原类型,强制其继承基类,可以对任何有共同行为的对象统一进行处理。实现运行时多态但是不继承,从而更容易使用不共享公共基类的现有类型。

下面我们来解释类型擦除的原理,内容参考自以下这篇问答

在C++中,所有的类型擦除技术,本质上都是用函数指针(表示行为)和void*指针(表示数据)。为什么要这么设计呢,因为C++是编译期强类型语言,编译时会做类型检测,但是一到运行时,机器就不知道具体是什么类型了,比如说我现在写一行给int赋值的代码,之后使用void*指针指向这个int。编译器编译完成后,在运行时,p 只是一个裸指针,机器并不知道它指向的是 int。所以如果我们想在运行时恢复出“这是什么类型”或“如何操作它”,我们必须手动保存这两类信息:void*指针表示对象的实际数据,函数指针表示对象的行为。这两项合在一起,构成类型擦除的全部基础。

各种不同的实现方法只是添加了不同层次的“语法糖”而已,举例说明,虚函数机制本质上就是编译期帮你自动生成一个虚函数表结构,每个对象里保存一个指向这张表的指针。所谓的运行时多态,无论是虚函数,继承还是手动模拟。核心逻辑都是:你有一个指向对象实际内容的void*,你有一组函数指针表告诉你这个对象有哪些行为,这样就不会在编译期被定死了,会在运行期进行指针的跳转拿到实际对象或者方法进行操作。

加入我写下这段代码:

struct Base {
    virtual void f(double);
    virtual ~Base();
};

逻辑上会生成类似下面的代码:

struct Base {
    vtable* vptr;
};

struct vtable {
    void (*dtor)(Base*);
    void (*f)(Base*, double);
};

其实就是生成了一个虚表,虚表里面存储着对象能做什么。子类也是同理,会生成子类重写虚函数的虚表,之后使用父类指针调用子类对象时就会根据这个虚表查子类能做什么,隐藏了子类的具体类型,却保留了调用的统一接口,这就实现了类型擦除。

下面再来举一个shared_ptr<void> 的类型擦除机制,const std::shared_ptr<void> sp(new A);在这段代码中,我们把一个A*转化为了void *,那么问题就来了,我们使用共享指针管理这块内存对象,按理来说,void*指针指向的对象在析构时不知道原始类型,怎么会在析构时正确的调用A::~A()

其实是因为共享指针内部除了存储裸指针之外还存储了一个控制块,这个控制块维护了引用计数,删除器之类的对象。在上面通过A的指针构造sp时,数据部分被擦除为void *,行为部分(如何销毁对象)通过函数对象(deleter) 存下来。看似实际类型被转化为了void*,但实际上类型信息被转移到了 deleter 闭包中。

下面给出一个简化的Function实现:

class CallableBase {
    public:
    virtual ~CallableBase() = default;
    virtual void call() const = 0;
};

template <typename Callable>
class CallableWrapper : public CallableBase
{
    public:
    CallableWrapper(Callable callable) :
    callable_(std::move(callable)) {}

    void call() const override {
        callable_();
    }

    private:
    Callable callable_;
};

class SimpleFunction
{
    public:
    template <typename Callable>
    SimpleFunction(Callable callable)
    : callableImpl_(std::make_shared<CallableWrapper<Callable>>(std::move(callable))) {}

    void operator()() const {
        if(callableImpl_)
            callableImpl_->call();
    }
    private:
    std::shared_ptr<CallableBase> callableImpl_;

};

其中第一段代码定义了一个虚基类CallableBase,这个类就是统一的行为接口,但是没有类型信息,也就是说在这里,类型信息已经被擦除了一半。之后这个子类CallableWrapper是一个模板类,它知道 Callable 的真实类型。构造函数把传进来的任意可调用对象存起来,然后重写关于对象行为的call()函数,这里的关键是每个不同类型的 Callable 都会生成一个独立的模板实例,但它们都通过同一个基类接口CallableBase对外暴露。

之后这个封装类 SimpleFunction是类型擦除真正发生的地方,构造时,传入任何类型的可调用对象,模板参数 Callable 推导出对象的真实类型,创建一个对应的 CallableWrapper<Callable>,此时这个类里面还存储了实际的对象信息,但我们存储时,只保留指向基类的 shared_ptr<CallableBase>。当我们把std::shared_ptr<CallableWrapper<Callable>>赋值给 std::shared_ptr<CallableBase> 时,类型擦除就发生了。

此时具体类型 Callable 的信息被隐藏,我们只通过基类接口 CallableBase 操作,运行时只看到统一的接口CallableBase::call(),但背后通过虚函数调用了不同类型对象的真实行为。