C++ 类型擦除:你对象是 Circle 还是 int 不重要,能 draw() 就行,我不挑

0 阅读18分钟

类型擦除这玩意吧,在 C++ 中只干一件事:

我们把一个具体类型塞进去,它偷偷藏好,然后对外装傻:“俺不知道啥类型,俺只知道你能 draw()、能 clone()、能 operator()”。

编译器本来就是个事儿逼,啥类型都得对得严丝合缝。

类型擦除就跟它说:“哥,别查了,都是自己人”。

想实现它?只需这几招:

  1. 用一个非模板基类声明我们要的操作(比如clone()、call())。
  2. 用一个模板派生类包装具体类型,把这些操作实现掉。
  3. 外面只持有基类指针。

这就是 std::function、std::any 的底层骨架。

类型擦除的基本思想

我们先了解一下它的基本思想,再谈别的。

1. 问题场景:当我们想要一个能装各种东西的容器

假设我们在写一个图形编辑器。

我们有 Circle、Rectangle、Triangle……它们都有 draw() 方法。我们想这样用:

std::vector<???> shapes;
shapes.push_back(Circle{5});
shapes.push_back(Rectangle{3,4});
for (auto& s : shapes) s.draw();

问题来了:vector 里的元素类型必须相同。

我们不能直接写 vector<Shape> 然后把派生类放进去,会导致对象切片,只保留 Shape 基类部分,多态没了。

我们当然可以用 vector<Shape*> 自己管理生命周期,但那是手动档,容易漏 delete。

更现代的做法是 vector<unique_ptr<Shape>>,但这就要求 Shape 是一个公共基类,所有图形都得继承它。

如果 Circle 来自第三方库、是个 int 或者 std::function<void()> 呢?我们没法改它的定义。

类型擦除要解决的就是:在不修改已有类型的前提下,让一个容器(或函数参数)能够统一地处理一组行为相同但类型无关的对象。

2. 类型擦除的核心

核心三要素:

  • 概念:我们要保留什么操作?例如“可以 draw”、“可以拷贝”、“可以析构”。这些构成一个隐式的接口
  • 模型:对每个具体类型(比如 Circle),写一个小的包装类(通常叫 Model<T>),它知道如何执行 T 的 draw(),并且以虚函数的形式暴露出去。
  • 擦除:对外只暴露一个非模板的、固定类型的外观类(比如 Drawable)。这个类内部持有一个指向 Model 基类的指针,通过虚函数分发调用。

一个极简的手工版本:

// 1. 抽象接口
struct DrawConcept 
{
    virtual void draw() const 0;
    virtual ~DrawConcept() = default;
};

// 2. 模型模板:适配任意 T
template<typename T>
struct DrawModel : DrawConcept 
{
    T obj;
    DrawModel(T o) : obj(std::move(o)) {}
    void draw() const override { obj.draw(); }
};

// 3. 擦除后的外观类
class Drawable 
{
    std::unique_ptr<DrawConcept> pimpl;
public:
    template<typename T>
    Drawable(T t) : pimpl(std::make_unique<DrawModel<T>>(std::move(t))) {}

    void draw() const { pimpl->draw(); }
};

我们来试试调用它:

struct Circle 
{
    void draw() const { std::cout << "画一个圆" << std::endl; }
};

struct Square 
{
    void draw() const { std::cout << "画一个正方形" << std::endl; }
};

struct Triangle 
{
    void draw() const { std::cout << "画一个三角形" << std::endl; }
};

int main()
{
    // 用 Drawable 擦除不同类型,存入同一个 vector
    std::vector<Drawable> shapes;

    shapes.emplace_back(Circle{});
    shapes.emplace_back(Square{});
    shapes.emplace_back(Triangle{});

    // 统一调用 draw()
    for (const auto& shape : shapes) shape.draw();
        
    return 0;
}

最后输出:

画一个圆
画一个正方形
画一个三角形

我们可以往里面扔任何东西,甚至一个普通的 int(只要为 int 定义了 draw() 或者单独适配)。

因为这些东西的类型信息被擦除成了 DrawConcept 的那一组虚函数表,这就是名字的由来。

3. 类型擦除 vs 模板

这时候爱提问的小明就要问了:模板不也能接受任意类型吗?

它们之间还是有区别的:

维度模板类型擦除
绑定时间编译期,每个实例化类型产生一份独立代码运行期,通过虚函数表动态分发
容器内类型不同模板实参会生成不同类型,不能放同一个 vector擦除后变成同一类型(如 Drawable),可以混放
代码膨胀可能较大(每个类型一份逻辑)较小(只有模型模板生成少量代码,外观类非模板)
性能零开销,可内联虚函数调用开销(一般可接受)
典型用途算法、容器(std::vector<T>)运行时多态容器、std::function、std::any

一句话总结:

  • 模板:“你告诉我类型,我编译时给你造个专属工具。”
  • 类型擦除:“你拿来什么类型,我用同一套工具箱帮你包一层,运行时我再偷偷调那个类型的正确方法。”

4. 记忆小剧场:不知道当时怎么想的,可跳过

(当时觉得挺有趣的,现在觉得挺尬的,果然人不能共情之前的自己,哪怕一天都不行)

厨房里,一台智能喂食器立在墙角。猫、狗、仓鼠排排坐,等着干饭。

阿黄 ( ◜◡‾):“铁疙瘩,给我来份大份狗粮!多加肉!”

花花 (´~`):“你喊辣么大声干什么嘛?它根本不认识你。”

团子 (✪ω✪):“对对对,它只知道一件事,站上去的家伙都能‘吃’。”

阿黄 (◔౪◔):“啥?连我是谁都分不清?那我冒充团子,岂不是能爽吃双份?”

花花 (´-ω-`):“你站上去试试?”

阿黄 (。◕∀◕。):“……那我贴个‘猫’的标签总行吧?”

团子 (๑╹◡╹๑):“我试过!我贴了‘狗’的标签站上去,结果掉出来的还是五谷丸子。”

花花 (╬゚д゚):“就算你们贴个‘我是如来佛祖’也没用。它早把‘类型’擦掉了,只留下‘能吃饭’这个行为。”

阿黄 (・∀・):“那明天我减肥成功,是不是就能冒充仓鼠了?”

花花 (・`ω´・):“你就算瘦成个杆子,也是电线杆。就你那百来斤往称上一压,它就知道这玩意绝对不是仓鼠。”

团子 (◞‸◟):“类型擦除了,但体重没有。这大概就是生活的残酷吧。”

阿黄(⌓‿⌓):“喂食器大人,别看我外表是只狗,但我内心其实是一只仓鼠,请给我五谷丸子。”

喂食器 ( •́ _ •̀)?:“请勿放置煤气罐!”

花花 (๑¯∀¯๑):“哈哈哈哈!”

阿黄 ( ˘・з・):“得,我还是老老实实当我的煤气罐吧……那个,猫粮能不能匀我一口?”

(类型擦除:只关心 void eat(Portion),不关心我们是 class Dog 还是 class Cat。)

现代 C++ 实现技术

没啥好说的,直接进入正题。

1. std::function

std::function 能装任何可以 () 调用的东西:函数指针、lambda、有 operator() 的类对象,甚至成员函数。

它是怎么擦除类型?

本质上就是我们之前写的 Drawable 的翻版,只不过把 draw() 换成了 operator()。

  • 概念:可复制构造 + 可析构 + 可调用(给定参数类型 Args... 返回 R)。
  • 模型:模板类 <typename T> 内部存一个 T,实现 operator() 转发给 T::operator()。
  • 擦除:外观类 function<R(Args...)> 持有一个指向抽象基类 callable_base 的指针,基类有虚函数 invoke 和 clone(用于拷贝)。

关键优化:小对象优化(SBO)

很多实现不会一上来就 new。

如果可调用对象大小不超过一个指针(比如 16 或 32 字节),就直接存在 function 对象内部的缓冲区里,避免堆分配。

这就是为什么我们塞一个 lambda []{}(通常 1 字节)进去不会触发 new。

核心代码骨架(简化,去掉了 SBO):

template<typename T>
class function;

template<typename Ret, typename... Args>
class function<Ret(Args...)> 
{
    struct callable_base 
    {
        virtual Ret invoke(Args... args) 0;
        virtual callable_base* clone() const 0;
        virtual ~callable_base() = default;
    };

    template<typename F>
    struct callable_model : callable_base 
    {
        F f;
        callable_model(F&& f_) : f(std::forward<F>(f_)) {}
        Ret invoke(Args... args) override return f(args...); }
        callable_base* clone() const override return new callable_model(f); }
    };

    callable_base* ptr;
    
public:
    template<typename F>
    function(F f) : ptr(new callable_model<F>(std::move(f))) {}
    // 拷贝、移动、析构...
    Ret operator()(Args... args) return ptr->invoke(args...); }
};

ps:这段代码只是为了阐述类型擦除的核心思想,故省略些细节。

2. std::any

std::any 比 function 更极端:它只保留拷贝/移动/析构的能力,不保留任何业务操作。

我们只能问它“你里面有东西吗?”、“能换成别的类型吗?”、“能取出原来的类型吗?”。

原理:更简单的类型擦除,连 invoke 虚函数都没有,只有 clone 和析构。

class any 
{
    struct holder_base 
    {
        virtual holder_base* clone() const 0;
        virtual ~holder_base() = default;
    };

    template<typename T>
    struct holder : holder_base 
    {
        T value;
        holder(T&& v) : value(std::move(v)) {}
        holder_base* clone() const override return new holder(value); }
    };

    holder_base* content;

public:
    template<typename T>
    any(T&& v) : content(new holder<std::decay_t<T>>(std::forward<T>(v))) {}

    // 拷贝、移动、析构...
    template<typename T>
    T& any_cast() 
    {
        if (auto* h = dynamic_cast<holder<T>*>(content); h) return h->value;
        throw bad_any_cast();
    }
};

因为 any_cast<T> 依赖 RTTI 即运行时类型识别(使用 dynamic_cast 或手写 typeid 比较)。

所以 any 会擦除类型,但保留了一个 type_index 用于运行时安全检查。

这是它和 std::variant 的重要区别:variant 的类型集编译期固定,不需要 RTTI。

3. std::variant vs 类型擦除

先来介绍一下 std::variant 的基础概念:它提供一种类型安全的方式来存储和访问多种不同类型的值。

一个 std::variant 对象在任何时刻只能包含其定义的类型之一的值,或者在出错的情况下不包含任何值。

这么一看 variant 是不是和 any 差不多呢?

其实它们是互补的:

特性std::variant<A,B,C>类型擦除(any / function)
类型集合编译期固定,显式列出运行期无限,只要满足概念
存储方式栈上 union(无堆分配)通常堆分配 + 可能的小对象优化
访问方式std::visit 或 get<...>any_cast / operator()
错误处理编译期检查运行时抛异常或返回空
性能极快(无虚函数,无堆)有虚函数或函数指针开销
适用场景我们知道所有可能类型的有限集合我们不知道所有类型,但知道它们能做的操作

variant 没有进行类型擦除,它保留了完整的类型信息(只是用 union 安全地存其中之一)。

类型擦除的核心是丢掉具体类型名,只保留操作表。

variant 恰恰相反:我们必须知道具体类型才能 get 或 visit。

理解了它们的取舍,我们就懂了 C++ 的一些设计哲学:用编译期的复杂换运行期的灵活,或者反过来

性能优化:小对象优化(SBO)

这是类型擦除里特别好的一个优化,毕竟大部分时候我们的对象很小,干嘛非要上堆呢?

1. 问题:每次擦除都进行堆分配,影响性能

我们之前写的 Drawable 是这样的:

template<typename T>
Drawable(T t) : pimpl(std::make_unique<DrawModel<T>>(std::move(t))) {}

每次构造 Drawable,都会 new 一个 DrawModel<T> 对象。

这有几个问题:

  • 堆分配开销:new/delete 涉及系统调用或内存池操作,比栈操作慢几十到几百倍。
  • 缓存不友好:每个 Drawable 对象里只有一个指针,真正的数据散落在堆上。遍历 vector<Drawable> 时,每次访问都要间接寻址,缓存 miss 率高。
  • 内存碎片:频繁分配小对象(比如一个 int 的模型对象也就一两个指针大小)会导致堆碎片。

但是,很多场景下擦除的对象其实很小:

  • std::function 里装一个无捕获的 lambda(通常 1 字节)
  • 我们的 Drawable 装一个 Circle。
  • std::any 装一个 int 或指针

如果能让这些小对象直接存储在 Drawable 对象内部,不经过堆,性能就会大幅提升,这就需要 SBO 登场。

2. 原理:就地存储,超过容量再上堆

SBO 的核心思想很简单:在 Drawable 对象内部预留一块原始字节缓冲区(比如 32 字节),外加一个对齐保证

  • 如果 T 的大小 ≤ 缓冲区大小,且 T 满足某些条件,就把 T 直接 placement new 到缓冲区里。
  • 否则,还是走堆分配。

关键点:Drawable 必须能够区分当前是“小对象存本地”还是“大对象上堆”,并且对于拷贝/移动/析构要分别处理两种情况。

通常用一个控制块指针来标记,或者把指针和缓冲区放在一个 union(联合体)里。

3. 实现要点

如果我们给之前的 Drawable 加 SBO,那么就需要改这几个地方:

3.1 定义缓冲区大小和对齐

static constexpr size_t buffer_size = 32// 预留 32 字节栈内存,存放小型对象
static constexpr size_t buffer_align = alignof(std::max_align_t); // 获取平台最大基础对齐,作为缓冲区对齐值
alignas(buffer_align) std::byte buffer[buffer_size]; // 声明一块对齐良好的原始内存缓冲区

一些说明:

  • alignof(type-id):C++11 引入的运算符,返回由 type-id 指示的对齐要求(以字节为单位)。
  • std::max_align_t:其对齐要求至少与所有标量类型一样严格(通常等于平台最大基础对齐,例如 8 或 16 字节)。
  • std::byte:C++17 引入的字节类型,专门用于表示原始内存,比 char 语义更清晰。

3.2 修改概念基类

原来的 DrawConcept 只有 draw() 和虚析构。

为了支持本地存储,基类提供一个 clone() 方法,用于将自身拷贝到给定的目标缓冲区,否则返回一个新的堆对象指针。

不过我们也可以让模型类自己决定是放在堆上还是栈上。

外观类通过一个标志位(比如 bool is_small)来区分。

简化版:我们在外观类中直接通过类型判断是否用小对象。

模板模型类 DrawModel<T> 本身可以存储 T 对象,它的大小就是 sizeof(T)。

我们只需要决定 DrawModel<T> 对象放在哪,缓冲区还是堆。

一个小细节:DrawModel<T> 是一个完整的对象,包含虚表指针和 T。

所以判断大小应该是 sizeof(DrawModel<T>) 而不是 sizeof(T)。

因为虚表指针的存在使得小对象优化阈值要减去 sizeof(void*)。

不过我们只是简单实现,所以直接判断 sizeof(DrawModel<T>) <= buffer_size。

3.3 修改外观类,存储方式改为 union

class Drawable 
{
    union Storage 
    {
        alignas(buffer_align) std::byte local[buffer_size];
        Storage() {}
        ~Storage() {}
    } storage; 
    
    bool is_small; // true: 使用 local 缓冲区; false: 使用 heap
};

为了简化演示,union 就设计的简单些。

3.4 构造时的分支逻辑

template<typename T>
Drawable(T&& t) 
{
    using Model = DrawModel<std::decay_t<T>>;
    constexpr bool use_small = (sizeof(Model) <= buffer_size) &&
          (alignof(Model) <= buffer_align) &&
          std::is_nothrow_move_constructible_v<Model>; // 判断 Model 类型在进行移动构造时能否不抛出异常。
                              
    if constexpr (use_small) 
    {
        // 小对象:placement new 到 local 缓冲区
        new (&storage.local) Model(std::forward<T>(t));
        ptr = reinterpret_cast<DrawConcept*>(&storage.local);
        is_small = true;
    } else 
    {
        // 大对象:堆分配
        ptr = new Model(std::forward<T>(t));
        is_small = false;
    }
}

ptr 是 Drawable 的一个 DrawConcept* 成员变量。

这样统一访问:ptr->draw()。

3.5 析构、拷贝、移动的分支

  • 析构:如果 !is_small,delete ptr;否则需要显式调用析构函数(ptr->~DrawConcept()),因为 placement new 的对象不会自动析构。
  • 拷贝构造:需要根据源对象的 is_small 决定是拷贝整个缓冲区还是 new 一个新对象。
  • 移动构造:类似。

这些细节实现起来比较啰嗦,但核心就是分两条路处理。

4. 示例:为前面的 Drawable 添加 SBO

啰嗦了这么久,思路理的应该差不多了,现在我们就动手实现一个完整精简版示例:

概念基类

struct DrawConcept 
{
    virtual void draw() const 0;
    virtual ~DrawConcept() = default;
    virtual DrawConcept* clone() const 0// 堆上克隆(用于大对象)
    virtual void copy_to(void* dest) const 0// 就地拷贝到 dest(用于小对象)
    virtual void move_to(void* dest) 0// 移动同理
};

模型模板

template<typename T>
struct DrawModel final : DrawConcept 
{
    T obj;
    explicit DrawModel(T&& o) : obj(std::move(o)) {}
    explicit DrawModel(const T& o) : obj(o) {}
    void draw() const override { obj.draw(); }
    DrawConcept* clone() const override return new DrawModel(obj); }
    void copy_to(void* dest) const override new (dest) DrawModel(obj); }
    void move_to(void* dest) override new (dest) DrawModel(std::move(obj)); }
};

带 SBO 的 Drawable

class Drawable 
{
    static constexpr size_t buffer_size = 32;
    static constexpr size_t buffer_align = alignof(std::max_align_t);

    // 存储
    union Storage 
    {
        alignas(buffer_align) std::byte local[buffer_size];
        Storage() {}
        ~Storage() {}
    } storage;

    DrawConcept* ptr; // 统一接口指针
    bool is_small; // true: 使用 local 缓冲区; false: 使用 heap

    // 销毁当前对象
    void destroy() noexcept 
    {
        if (ptr) {
            if (!is_small) 
            {
                delete ptr; // 堆对象
            }
            else 
            {
                ptr->~DrawConcept(); // 本地对象,显式调析构
            }
            ptr = nullptr;
        }
    }

    // 深拷贝
    void copy_from(const Drawable& other) 
    {
        if (other.ptr == nullptr) 
        {
            ptr = nullptr;
            is_small = false;
            return;
        }

        if (!other.is_small) 
        {
            // 大对象:直接 clone 得到堆上对象
            ptr = other.ptr->clone();
            is_small = false;
        }
        else 
        {
            // 小对象:placement new 到本地缓冲区
            other.ptr->copy_to(&storage.local); // 拷贝构造
            ptr = reinterpret_cast<DrawConcept*>(&storage.local);
            is_small = true;
        }
    }

    // 从另一个 Drawable 移动
    void move_from(Drawable&& other) noexcept 
    {
        if (other.is_small) 
        {
            other.ptr->move_to(&storage.local);
            ptr = reinterpret_cast<DrawConcept*>(&storage.local);
            is_small = true;
            other.ptr->~DrawConcept(); // 销毁源对象
            other.ptr = nullptr;
        }
        else 
        {
            // 大对象
            ptr = other.ptr;
            is_small = false;
            other.ptr = nullptr;
        }
    }

public:
    Drawable() : ptr(nullptr), is_small(false) {}

    // 模板构造:决定用小对象还是堆
    template<typename T, typename = std::enable_if_t<!std::is_same_v<std::decay_t<T>, Drawable>>>
    Drawable(T&& t) 
    {
        using Model = DrawModel<std::decay_t<T>>;
        constexpr bool use_small = (sizeof(Model) <= buffer_size) &&
            (alignof(Model) <= buffer_align) &&
            std::is_nothrow_move_constructible_v<Model>;

        if constexpr (use_small) 
        {
            // 小对象:placement new 到 local 缓冲区
            new (&storage.local) Model(std::forward<T>(t));
            ptr = reinterpret_cast<DrawConcept*>(&storage.local);
            is_small = true;
        }
        else 
        {
            // 大对象:堆分配
            ptr = new Model(std::forward<T>(t));
            is_small = false;
        }
    }

    // 拷贝构造
    Drawable(const Drawable& other) { copy_from(other); }

    // 移动构造
    Drawable(Drawable&& other) noexcept { move_from(std::move(other)); }

    // 拷贝赋值
    Drawable& operator=(const Drawable& other) 
    {
        if (this != &other) 
        {
            destroy();
            copy_from(other);
        }
        return *this;
    }

    // 移动赋值
    Drawable& operator=(Drawable&& other) noexcept 
    {
        if (this != &other) 
        {
            destroy();
            move_from(std::move(other));
        }
        return *this;
    }

    ~Drawable() { destroy(); }

    void draw() const if (ptr) ptr->draw(); }
};

ps:上面代码为了展示 SBO 的结构,在移动和拷贝上做了简化。

所以对于 std::function 来说,大多数 lambda 都很小,有了 SBO,它们几乎永远不会触发堆分配,性能接近裸函数指针。

这就是为什么现代 C++ 里用 std::function 传 lambda 往往比我们想的要快。

补充说明

前面我们搭好了 Drawable 的基本骨架和 SBO,现在再进行些补充。

1. 多操作接口

实际场景中,被擦除的类型往往需要多个操作。

比如图形对象除了 draw(),可能还要 rotate()、scale()、serialize()。

类型擦除的威力在于:我们可以定义一组操作(一个概念),然后让模型模板实现它们。

在 Concept 基类里加更多纯虚函数

struct DrawConcept 
{
    virtual ~DrawConcept() = default;
    virtual void draw() const 0;
    virtual void rotate(double angle) 0;
    virtual void scale(double factor) 0;
    virtual std::vector<charserialize() const 0;
};

模型模板为每个操作转发:

template<typename T>
struct DrawModel : DrawConcept 
{
    T obj;
    // ... 构造等
    void draw() const override { obj.draw(); }
    void rotate(double angle) override { obj.rotate(angle); }
    void scale(double factor) override { obj.scale(factor); }
    std::vector<charserialize() const override return obj.serialize(); }
    // ... clone/copy_to/move_to
};

如果我们有很多操作,每个 DrawModel 都要写一堆转发,很烦。

可以用一个辅助模板 ModelBase<T> 来自动生成转发,偷个懒就不写了。

2. 拷贝与移动语义

类型擦除的对象通常是值语义的,因此我们希望 Drawable 用起来像 int 一样:拷贝独立、移动高效。

那么需要哪些能力?

  • 拷贝构造:深拷贝内部对象。
  • 拷贝赋值:同上。
  • 移动构造:转移所有权,源对象变为空。
  • 移动赋值:同上。

前面我们写的 clone() 和 copy_to/move_to:

  1. 小对象的拷贝必须是非堆的

我们已经用 copy_to 解决了这个问题,在目标缓冲区 placement new 一个新对象。

  1. 移动后源对象的状态

对于小对象,move_to 会窃取资源,但源对象仍然存在于它的缓冲区中。

所以我们需要在移动后销毁源对象(调用析构)并标记源对象为空。

这里我们调用 other.destroy(),让 other 变为空 Drawable。

  1. 赋值操作符要考虑自赋值

虽然很少出现自赋值,但保险起见我们还是检查了一下。

详情请看拷贝赋值那部分代码。

  1. 移动赋值时,被赋值对象要先释放自己的资源

和拷贝赋值一样,但移动时可以直接窃取。

如果 this 已经持有资源,必须先释放,否则容易内存泄漏。

正确的顺序:destroy() → 然后从 other 移动资源 → 将 other 置空。

我们不能简单地交换指针,因为小对象涉及缓冲区。

详情请看移动赋值那部分代码。

3. 类型查询与安全向下转换

有时我们需要知道擦除后的对象原始类型,例如“如果它是 Circle,就调用 set_radius()”。

这是 std::any 和 std::variant 提供的功能。

我们可以先在 DrawConcept 中加入虚函数 const std::type_info& type() const。

然后模型模板实现它:

const std::type_info& type() const override return typeid(T); }

在外观类中提供 type() 和 is<T>():

const std::type_info& type() const return ptr->type(); }

template<typename T>
bool is() const 
{
    return type() == typeid(T);
}

对于 as() 的实现,我们也在 DrawConcept 中添加虚函数 void* get_target(const std::type_info&)。

在模型模板中比较类型并返回 &obj 或 nullptr。

voidget_target(const std::type_info& ti) override 
{
    if (ti == typeid(T)) return &obj;
    return nullptr;
}

然后是在外观类中:

template<typename T>
T* as() 
{
    if (ptr && ptr->get_target(typeid(T))) 
        return static_cast<T*>(ptr->get_target(typeid(T)));
    return nullptr;
}

使用示例:

Drawable d = Circle{};
if (d.is<Circle>()) 
{
    d.as<Circle>()->draw();
}

结尾

回顾一下吧:

我们从最初的手写 Drawable,到 std::function 和 std::any 的剖析,再到小对象优化和多操作接口。

我们会发现:

  • 类型擦除的本质不是丢掉类型,而是有选择地遗忘。我们只忘记具体类型名,却牢牢记住了一组操作。

  • 三种经典实现:

    • std::function 擦除到可调用,是回调的救世主。
    • std::any 擦除到可存可取,极大的提高了灵活性。
    • std::variant 拒绝擦除,用编译期 union 换取极致性能。
  • 小对象优化(SBO):只需加上 32 字节的栈缓冲区,就能让小对象避开堆分配。