文章目录
- 一、C++类型擦除Type Erasure技术
- 1.虚函数
- 2.模板和函数对象
- 二、任务队列
- 1.基于特定类型的方式实现
- 2.基于任意类型的方式实现
- 参考:
一、C++类型擦除Type Erasure技术
C++中的类型擦除(Type Erasure)是一种技术,用于隐藏具体类型并以类型无关的方式处理对象。 它允许在运行时处理不同类型的对象,同时提供一致的接口和行为。
类型擦除常用于实现泛型编程和多态性,其中需要处理不同类型的对象,但又希望以一致的方式进行操作和处理。
两个常见的类型擦除技术:虚函数,模板和函数对象
1.虚函数
- 使用虚函数是一种简单的类型擦除技术,通过将函数声明为虚函数,可以在派生类中重写该函数以提供具体实现。
- 然后,可以使用基类指针或引用来处理不同派生类的对象,而无需关心具体的类型。 虚函数机制提供了动态派发的能力,使得在运行时选择正确的函数实现。
派生类是普通类
class Base {
public:virtual void foo() {// 基类默认实现}
};class Derived1 : public Base {
public:void foo() override {// 派生类1的实现}
};class Derived2 : public Base {
public:void foo() override {// 派生类2的实现}
};void process(Base& obj) {obj.foo(); // 调用适当的派生类实现
}int main() {Derived1 d1;Derived2 d2;process(d1); // 调用Derived1的foo()process(d2); // 调用Derived2的foo()return 0;
}
在上述示例中,
- Base类具有虚函数 foo(),并且它的派生类 Derived1 和 Derived2 分别提供了自己的实现。
- process() 函数接受 Base 类型的引用,可以在运行时根据实际的对象类型来调用适当的 foo() 实现。
派生类是模板类
#include <iostream>struct Base {virtual void foo() const = 0;
};template <typename T>
struct Derived : public Base {void foo() const override {std::cout << "Derived<" << typeid(T).name() << ">::foo()" << std::endl;}
};void process(const Base& obj) {obj.foo(); // 调用适当的派生类实现
}int main() {Derived<int> d1;Derived<double> d2;process(d1); // 调用Derived<int>::foo()process(d2); // 调用Derived<double>::foo()return 0;
}
在上述示例中,Base 是一个抽象基类,其派生类 Derived 是一个模板类。
- 模板参数 T 表示派生类的具体类型。通过在 Derived 类中使用 typeid 和 name(),可以在运行时获取具体类型的信息。
- process() 函数接受 Base 类型的常量引用,并调用适当的 foo() 实现。
2.模板和函数对象
另一种类型擦除的方法是使用模板和函数对象(Functor)。通过使用模板和函数对象,可以将类型信息推迟到运行时,并以一致的方式使用对象。
使用仿函数
- 这个例子展示了如何使用函数对象实现类型擦除,通过函数对象的模板化操作符 operator(),我们可以在运行时以一致的方式处理不同类型的对象。
#include <iostream>
#include <typeinfo>struct Base {virtual void foo() const = 0;
};struct Functor {template <typename T>void operator()(const T& obj) const {std::cout << "Functor: " << typeid(T).name() << std::endl;obj.foo();}
};template <typename T>
struct Derived : public Base {void foo() const override {std::cout << "Derived<" << typeid(T).name() << ">::foo()" << std::endl;}
};int main() {Derived<int> d1;Derived<double> d2;Functor f;f(d1); // 调用Derived<int>::foo()f(d2); // 调用Derived<double>::foo()return 0;
}
-
我们定义了一个函数对象 Functor,其中的 operator() 是一个模板函数。函数对象通过调用 obj.foo() 来执行对象的 foo() 方法,并在控制台上打印相关信息。Derived 类是一个模板类,它继承自 Base 类,实现了 foo() 方法。
-
在 main() 函数中,我们创建了两个不同类型的 Derived 对象 d1 和 d2,然后创建了一个 Functor 对象 f。通过调用 f(d1) 和 f(d2),我们将不同类型的对象传递给函数对象 f,它将根据对象的类型调用适当的 foo() 实现。
使用std::function
- 通过使用 std::function,我们可以将不同类型的可调用对象进行类型擦除,并以一致的方式进行处理。
#include <iostream>
#include <functional>struct Base {virtual void foo() const = 0;
};struct Derived1 : public Base {void foo() const override {std::cout << "Derived1::foo()" << std::endl;}
};struct Derived2 : public Base {void foo() const override {std::cout << "Derived2::foo()" << std::endl;}
};void process(const std::function<void()>& func) {func(); // 调用适当的函数实现
}int main() {Derived1 d1;Derived2 d2;std::function<void()> func1 = [&d1]() { d1.foo(); };std::function<void()> func2 = [&d2]() { d2.foo(); };process(func1); // 调用Derived1::foo()process(func2); // 调用Derived2::foo()return 0;
}
-
在这个示例中,我们定义了 Base 类和两个派生类 Derived1 和 Derived2。Base 类有一个纯虚函数 foo(),每个派生类都提供了自己的实现。
-
process() 函数接受一个 std::function<void()> 类型的参数,它表示一个无返回值、不带参数的可调用对象。通过传递不同的 std::function 对象给 process() 函数,我们可以在运行时选择适当的函数实现。
进一步,使用std::bind
- 使用 std::bind 可以实现类型擦除和延迟绑定,允许在运行时选择函数实现,并提供具体的参数值。
#include <iostream>
#include <functional>struct Base {virtual void foo(int value) const = 0;
};struct Derived1 : public Base {void foo(int value) const override {std::cout << "Derived1::foo(" << value << ")" << std::endl;}
};struct Derived2 : public Base {void foo(int value) const override {std::cout << "Derived2::foo(" << value << ")" << std::endl;}
};void process(const std::function<void()>& func) {func(); // 调用适当的函数实现
}int main() {Derived1 d1;Derived2 d2;auto func1 = std::bind(&Derived1::foo, &d1, 42);auto func2 = std::bind(&Derived2::foo, &d2, 24);process(func1); // 调用Derived1::foo(42)process(func2); // 调用Derived2::foo(24)return 0;
}
- 在示例中,func1 绑定了 Derived1::foo 成员函数,并提供了一个参数值 42。同样地,func2 绑定了 Derived2::foo 成员函数,并提供了一个参数值 24。通过调用 process() 函数,我们可以分别调用适当的函数实现。
二、任务队列
1.基于特定类型的方式实现
假设任务类如下所示:
//任务队列的类型是my_queue<std::unique_ptr<task_base>>,用基类指针去管理任务对象
class my_thread {using task_type = void(*)();my_queue<std::unique_ptr<task_base>> task_queue;//处理不同子类对象的run()的逻辑,可能实现void Loop() noexcept{for(auto& task: task_queue){task->run();}}
}; // 假设具体的任务函数体的调用签名都是void
struct task_base {virtual ~task_base() = 0;virtual void run() const = 0;
};// 用户编写的具体任务类
struct task_impl : public task_base { void run() const override {// 运算...}
};
优点:容易实现
缺点:非常缺乏伸缩性。
- 首先,编写子类的责任被推给了用户,可能一个不太复杂的函数调用会被强加上任务基类task_base的包装;
- 而且用起来也不方便。
2.基于任意类型的方式实现
使用类型擦除技术,这类设施典型的代表就是std::function,它通过类型擦除的技巧,不必麻烦用户编写继承相关代码,并能包装任意的函数对象。
C++语境下的类型擦除,技术上来说,是编写一个类,它提供模板的构造函数和非虚函数接口提供功能;隐藏了对象的具体类型,但保留其行为。
- 简单地说,就是库作者把面向对象的代码写了,而不是推给用户写:
- 首先,抽象基类task_base作为公共接口不变;
- 其子类task_model(角色同上文中的task_impl)写成类模板的形式,其把一个任意类型F的函数对象function_作为数据成员。
- 子类写成类模板的具体用意是,对于用户提供的一个任意的类型F,F不需要知道task_base及其继承体系,而只进行语法上的duck typing检查。 这种方法避免了继承带来的侵入式设计。 换句话说,只要能合乎语法地对F调用预先定义的接口,代码就可以编译,这个技巧就能运作。
- 此例中,预先定义的接口是void(),以functor_();的形式调用。
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_();}
};
然后,我们把它包装起来:
- 首先,初始动机是用一个类型包装不同的函数对象。
- 然后,考虑这些函数对象需要提供的功能(affordance),此处为使用括号运算符进行函数调用。
- 最后,把这个功能抽取为一个接口,此处为my_task,我们在在这一步擦除了对象具体的类型。
- 这便是类型擦除的本质:切割类型与其行为,使得不同的类型能用同一个接口提供功能。
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()();} // 移动构造函数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;}
};class my_thread {using task_type = void(*)();my_queue<my_task> task_queue;//处理不同子类对象的run()的逻辑,可能实现void Loop() noexcept{for(auto& task: task_queue){task();}}
};
测试:
对my_task进行简单测试的代码如下:
- 其实完全可以用std::function代替my_task,来实现类型擦除,这样连虚函数都不需要了;如果采用虚函数的方式,可以参考1和2的方法去设计
// 普通函数
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"
总结:
- 第一层是task_base。考虑需要的功能后,以虚函数的形式提供对应的接口I。
- 第二层是task_model。这是一个类模板,用来存放用户提供的类T,T应当语法上满足接口I;重写task_base的虚函数,在虚函数中调用T对应的函数。
- 第三层是对应my_task。存放一个task_base指针p指向task_model对象m;拥有一个模板构造函数,以适应任意的用户提供类型;以非虚函数的形式提供接口I,通过p调用m。
上述可能存在的问题1:
my_task t1{ &foo1 };/*
foo作为参数传递给一个函数模板时,会被“准确”地推断为函数类型void(),而不是函数指针类型void(*)(
*/
my_task t2{ foo1 }; // 编译出错,
-
解决办法1:简单的解决方法是,每次都记得用取地址运算符&
-
解决办法2:让模板将函数类型推导为函数指针类型void(*)(),修改my_task的构造函数为:
class my_task {template <typename F>my_task(F&& f) {// 使用std::decay来显式地进行类型退化// 如果传入函数类型就退化为函数指针类型using F_decay = std::decay_t<F>;using model_type = task_model<F_decay>; ptr_ = std::make_unique<model_type>(std::forward<F_decay>(f));}
};
模板元编程就是在编译时进行运算并生成代码的代码(所谓“元”)
上述可能存在的问题2:
// 复制构造my_task
my_task t1{[]() { std::cout << "type erasure"; }
};/*
事实上,如果这样去构造t2,编译器不会报错,但是运行时会栈溢出!如果查看栈记录,会发现程序一直在my_task的构造函数和task_model构造函数之间无限循环。Word?
*/
my_task t2{ t1 }; // 发生了什么?
从编译期函数解析的角度,分析这段代码可以通过编译的原因:
从t1复制构造t2时,编译器的第一选择是my_task的复制构造函数,但它被禁用了;
于是,编译器退而求其次地尝试匹配my_task的第一个构造函数,template my_task(F&&)。
而这个构造函数并没有限制F不能为my_task, 编译器就选择调用它。所以,这段代码可以过编译。
解决办法:
- 禁止my_task的模板构造函数的类型参数F为my_task
template <typename F>
using is_not_my_task = std::enable_if_t<!std::is_same_v< std::remove_cvref_t<F>, my_task >,int>;template <typename F, is_not_my_task<F> = 0>
my_task(F&& f);使用C++20 Concept
template <typename F>
concept is_not_my_task = !std::is_same_v<std::remove_cvref_t<F>, my_task>;class my_task {template <typename F> requires is_not_my_task<F>my_task(F&& f);
};
参考:
- 深入浅出C++类型擦除(1)
- 深入浅出C++类型擦除(2)