深入浅出 C++ 类型擦除,真香!

Posted 跟田老师学C++

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入浅出 C++ 类型擦除,真香!相关的知识,希望对你有一定的参考价值。

大家好,我是小北。

这次以实现一个任务类型为例,讲述一些使用 C++ 编程时的思考。

本文的前置知识是面向对象编程。

动机

最近,在写一个线程池,其中的每个线程都有一个与其相关联的任务队列,队列存放待运行的任务。此处的“任务”,指的是函数对象:https://en.cppreference.com/w/cpp/named_req/FunctionObject

它可以用括号运算符进行函数调用。函数对象主要分为函数指针、重载了括号运算符的类和lambda:

// 假设具体函数的调用都返回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
https://stackoverflow.com/a/20353798/13076225

几个尝试

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

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

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

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

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

// 假设具体的任务函数体的调用签名都是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?

auto lambda0 = []() { std::cout << "lambda0"; };
auto lambda1 = []() { std::cout << "lambda1"; };
auto lambda2 = []() { std::cout << "lambda2"; };

my_queue<???> task_queue;

这三个lambda看起来极其相似,但是它的类型各不相同。因此,虽然有一样的接口void(),但不能直接存放到同一个容器中去。如果通过std::is_same检查以上任意两个lambda类型是否一致,都会得到false

// 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_samevalue成员是一个编译期进行运算并得出结果的常量,当两个类型模板参数相等时为truestatic_assert用于编译时判断表达式是否为真。

我们能拥有这样的API吗:

struct my_task {
    template <typename F>
    my_task(F&& f); // 模板化的构造函数,
                    // 从任意类型的函数对象构建任务

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

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

类型擦除(Type Erasure)

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

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

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_作为数据成员。

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

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

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进行简单测试的代码如下:

// 普通函数
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

// 这是一个类模板,而非一个真正的类
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<int>,它是一个具体的类型(type),因此编译器能够直接用它的代码来生成汇编码。

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

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

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

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的相关函数:

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也用编译器合成的默认析构函数。


欢迎一起交流C++开发

请扫描下方二维码加田老师为微信好友

深入浅出 C++ 类型擦除,真香!

欢迎加入“C++/Qt”知识星球


记得帮我点赞/在看哦


以上是关于深入浅出 C++ 类型擦除,真香!的主要内容,如果未能解决你的问题,请参考以下文章

深入理解 Java 泛型擦除机制

Java泛型:类型擦除

从类型列表中擦除类型 C++ 元编程

具有特征的 C++ 类型擦除

Swift之深入解析基于闭包的类型擦除

[转]类型擦除