多态类型的连续存储

Contiguous storage of polymorphic types

本文关键字:存储 连续 类型 多态      更新时间:2023-10-16

我很想知道是否有任何可行的方法来连续存储多态对象数组,以便可以合法地调用公共基上的virtual方法(并将其调度到子类中正确的重写方法)。

例如,考虑以下类别:

struct B {
int common;
int getCommon() { return common; }
virtual int getVirtual() const = 0;
}
struct D1 : B {
virtual int getVirtual final const { return 5 };
}
struct D2 : B {
int d2int;
virtual int getVirtual final const { return d2int };
}

我想分配一个D1和D2对象的连续数组,并将它们视为B对象,包括调用getVirtual(),它将根据对象类型委托给适当的方法。从概念上讲,这似乎是可能的:每个对象都知道它的类型,通常通过嵌入的vtable指针,所以你可以想象,将n对象存储在n * max(sizeof(D1), sizeof(D2))unsigned char的数组中,并使用放置newdelete来初始化对象,并将unsigned char指针强制转换为B*。不过,我敢肯定演员阵容是不合法的。

人们也可以想象创建一个像这样的联盟

union Both {
D1 d1;
D2 d2;
}

然后创建Both的阵列,并使用placement new来创建适当类型的对象。然而,这似乎再次没有提供一种真正安全地调用B::getVirtual()的方法。您不知道元素的最后一个存储类型,那么如何获得B*?您需要使用&u.d1&u.d2,但您不知道是哪一个!实际上,有一些关于"初始公共子序列"的特殊规则,可以让你在元素共享一些公共特征的并集上做一些事情,但这只适用于标准布局类型。具有虚拟方法的类不是标准布局类型。

有什么办法继续吗?理想情况下,解决方案看起来像是一个非切片std::vector<B>,它实际上可以包含B的多态子类。是的,如果需要,可以规定所有可能的子类都是预先已知的,但更好的解决方案只需要知道任何子类的最大可能大小(如果有人试图添加"太大"的对象,则在编译时失败)。

如果不能使用内置的virtual机制,那么提供类似功能的其他替代方案也会很有趣。

背景

毫无疑问,有人会问"为什么",所以这里有一点动机:

众所周知,使用virtual函数实现运行时多态性在实际调用虚拟方法时会产生适度的开销。

然而,没有经常讨论的事实是,使用具有虚拟方法的类来实现多态性通常意味着管理底层对象的内存的方式完全不同。您不能只将不同类型(但有一个公共基)的对象添加到标准容器中:如果您有子类D1D2,它们都是从基B派生的,则std::vector<B>将对添加的任何D1D2对象进行切片。类似地,对于这样的对象的数组。

通常的解决方案是使用指向基类的指针的容器或数组,如std::vector<B*>std::vector<unique_ptr<B>>std::vector<shared_ptr<B>>。在访问1的每个元素时,这至少会增加一个额外的间接性,在智能指针的情况下,它会破坏常见的容器优化。如果您实际上是通过newdelete分配每个对象(包括间接分配),那么存储对象的时间和内存成本就会大大增加。

从概念上讲,似乎可以连续存储公共基的各种子类(每个对象将消耗相同的空间:支持的最大对象的空间),并且指向对象的指针可以被视为基类指针。在某些情况下,这可以大大简化并加快此类多态对象的使用。当然,总的来说,这可能是一个糟糕的想法,但就这个问题而言,让我们假设它有一些利基应用。


1除其他外,这种间接性在很大程度上阻止了对应用于所有元素的相同操作的任何矢量化,并损害了引用的局部性,这对缓存和预取都有影响。

你几乎和你的工会在一起了。您可以使用带标签的并集(在循环中添加一个if来进行区分)或std::variant(它通过std::find引入了一种双重调度来将对象从中取出)来实现这一点。在这两种情况下,您都没有在动态存储上进行分配,因此可以保证数据的位置性
无论如何,正如您所看到的,在任何情况下,您都可以用普通的直接调用替换额外级别的间接调用(虚拟调用)。你需要以某种方式擦除类型(多态性只不过是一种类型擦除,想想看),并且你不能用简单的调用直接从被擦除的对象中退出。需要CCD_ 33或额外的调用来填补额外间接级别的空白。

以下是使用std::variantstd::find:的示例

#include<vector>
#include<variant>
struct B { virtual void f() = 0; };
struct D1: B { void f() override {} };
struct D2: B { void f() override {} };
void f(std::vector<std::variant<D1, D2>> &vec) {
for(auto &&v: vec) {
std::visit([](B &b) { b.f(); }, v);
}
}
int main() {
std::vector<std::variant<D1, D2>> vec;
vec.push_back(D1{});
vec.push_back(D2{});
f(vec);
}

因为它真的很接近,所以不值得发布一个使用标记工会的例子。


另一种方法是通过派生类的单独向量和支持向量以正确的顺序迭代它们
下面是一个简单的例子:

#include<vector>
#include<functional>
struct B { virtual void f() = 0; };
struct D1: B { void f() override {} };
struct D2: B { void f() override {} };
void f(std::vector<std::reference_wrapper<B>> &vec) {
for(auto &w: vec) {
w.get().f();
}
}
int main() {
std::vector<std::reference_wrapper<B>> vec;
std::vector<D1> d1;
std::vector<D2> d2;
d1.push_back({});
vec.push_back(d1.back());
d2.push_back({});
vec.push_back(d2.back());
f(vec);
}

我试图在没有内存开销的情况下实现您想要的东西:

template <typename Base, std::size_t MaxSize, std::size_t MaxAlignment>
struct PolymorphicStorage
{
public:
template <typename D, typename ...Ts>
D* emplace(Ts&&... args)
{
static_assert(std::is_base_of<Base, D>::value, "Type should inherit from Base");
auto* d = new (&buffer) D(std::forward<Ts>(args)...);
assert(&buffer == reinterpret_cast<void*>(static_cast<Base*>(d)));
return d;
}
void destroy() { get().~Base(); }
const Base& get() const { return *reinterpret_cast<const Base*>(&buffer); }
Base& get() { return *reinterpret_cast<Base*>(&buffer); }
private:
std::aligned_storage_t<MaxSize, MaxAlignment> buffer;
};

演示

但问题是复制/移动构造函数(和赋值)是不正确的,但如果没有内存开销(或对类的额外限制),我看不出实现它的正确方法。

我不能=delete,否则你不能在std::vector中使用它们。

考虑到内存开销,variant似乎更简单。

所以,这真的很难看,但如果您不使用多重继承或虚拟继承,那么在大多数实现中,Derived *将具有与Base *相同的位级别值。

您可以用static_assert测试这一点,这样在特定平台上就无法编译,并使用您的union思想。

#include <cstdint>
class Base {
public:
virtual bool my_virtual_func() {
return true;
}
};
class DerivedA : public Base {
};
class DerivedB : public Base {
};
namespace { // Anonymous namespace to hide all these pointless names.
constexpr DerivedA a;
constexpr const Base *bpa = &a;
constexpr DerivedB b;
constexpr const Base *bpb = &b;
constexpr bool test_my_hack()
{
using ::std::uintptr_t;
{
const uintptr_t dpi = reinterpret_cast<uintptr_t>(&a);
const uintptr_t bpi = reinterpret_cast<uintptr_t>(bpa);
static_assert(dpi == bpi, "Base * and Derived * !=");
}
{
const uintptr_t dpi = reinterpret_cast<uintptr_t>(&b);
const uintptr_t bpi = reinterpret_cast<uintptr_t>(bpb);
static_assert(dpi == bpi, "Base * and Derived * !=");
}
// etc...
return true;
}
}
const bool will_the_hack_work = test_my_hack();

唯一的问题是constexpr规则将禁止对象具有虚拟析构函数,因为这些析构函数将被认为是"非平凡的"。您必须通过调用一个虚拟函数来销毁它们,该函数必须在每个派生类中定义,然后直接调用析构函数。

但是,如果这段代码成功编译,那么从联合的DerivedADerivedB成员获得Base *也没关系。无论如何,他们都会一样。

另一种选择是在一个结构的开头嵌入一个指向一个充满成员函数指针的结构的指针,该结构包含该指针和与派生类的并集,然后自己初始化它。基本上,实现您自己的vtable。

在CppCon 2017上有一个演讲,"运行时多态性-回到基础",讨论了做一些你想要的事情。幻灯片在github上,演讲视频在youtube上。

扬声器的实验库"dyno"也在github上。

在我看来,您正在寻找一个variant,它是一个具有安全访问的标记联合。

c++17具有CCD_ 47。对于以前的版本,boost提供了一个版本-boost::variant

请注意,多态性不再是必要的。在这种情况下,我使用了签名兼容的方法来提供多态性,但您也可以通过签名兼容的免费函数和ADL来提供它。

#include <variant>   // use boost::variant if you don't have c++17
#include <vector>
#include <algorithm>
struct B {
int common;
int getCommon() const { return common; }
};
struct D1 : B {
int getVirtual() const { return 5; }
};
struct D2 : B {
int d2int;
int getVirtual() const { return d2int; }
};
struct d_like
{
using storage_type = std::variant<D1, D2>;
int get() const {
return std::visit([](auto&& b)
{
return b.getVirtual();
}, store_);
}
int common() const { 
return std::visit([](auto&& b)
{
return b.getCommon();
}, store_);
};
storage_type store_;
};
bool operator <(const d_like& l, const d_like& r)
{
return l.get() < r.get();
}
struct by_common
{
bool operator ()(const d_like& l, const d_like& r) const
{
return l.common() < r.common();
}
};
int main()
{
std::vector<d_like> vec;
std::sort(begin(vec), end(vec));
std::sort(begin(vec), end(vec), by_common());
}