问题 连续存储多态类型


我很想知道是否有任何可行的方法来连续存储多态对象数组,这样 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() 它将根据对象类型委托给适当的方法。从概念上讲这似乎是可能的:每个对象 知道 它的类型,通常通过嵌入式 虚函数表 指针,所以你可以想象,存储 ñ 数组中的对象 n * max(sizeof(D1), sizeof(D2))  unsigned char,并使用展示位置 new 和 delete 初始化对象,并铸造 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 实现运行时多态性的函数来自于 适度开销 当实际调用虚方法时。

然而,并不是经常讨论的事实是,使用具有虚方法的类来实现多态性通常意味着管理底层对象的内存的完全不同的方式。您不能只将不同类型的对象(但是一个共同的基础)添加到标准容器中:如果您有子类 D1 和 D2,都来自基地 B, 一个 std::vector<B> 会切片 D1 要么 D2 添加的对象。类似地,对于这种对象的数组。

通常的解决方案是使用容器或数组 指针 像基类一样 std::vector<B*> 也许 std::vector<unique_ptr<B>> 要么 std::vector<shared_ptr<B>>。至少,这在访问每个元素时增加了额外的间接1,在智能指针的情况下,它会中断 常见的容器优化。如果你实际上是通过分配每个对象 new 和 delete (包括间接),然后存储对象的时间和内存成本增加了很多。

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


1 除此之外,这种间接性几乎可以防止应用于所有元素的相同操作的任何矢量化并损害引用的局部性,同时影响缓存和预取。


8476
2017-10-09 04:48


起源

那里的OP 只是想要单独的容器,但没有想到它。我的意思是它解决了OP所遇到的XY问题。对于需要订购的情况,这是一个很好的问题。我还没看过你得到的asm std::visit 在一个容器上 std::variant<D1,D2>。用那个 代替 但是,vtable(使用非虚拟覆盖)因为variant使用自己的标记,而不是vtable ptr。 - Peter Cordes
这里的 asm 为了 std::variant 做法。这并不可怕。与“指针向量”方法相比,它至少削减了一个间接层。它基本上使用变量索引值来索引跳转表 - 所以非常像vtable,除非你实际上不需要查找vtable来使用。有一些额外的代码来检查索引是不是 -1 指示一个“空”变量,并且跳转的函数似乎检查变量是否具有“预期”类型,这似乎是不必要的(您只是在那里调度...)。 @PeterCordes - BeeOnRope
@PeterCordes - 这个 可能是你想要的:我有 return 0 代替 return total 在优化一切的原始代码中,oops。还不错,但仍然有一个“额外”检查的类型 else case(我想它需要它,支持空案例)。至少一切都是内联的。可能只是用一个有区别的联盟建立这个会更好。 - BeeOnRope
它是故意这样做的。使用 -fno-isolate-erroneous-paths-dereference(godbolt.org/g/KUsmLs)将null-deref视为 __builtin_unreachable() 代替。 (以正面形式记录,启用时间 -O2 更高 - Peter Cordes
@PeterCordes - 很酷,我学到了一些新的东西,但它仍然不起作用,因为我希望有这个选项。看看gcc在标签上做了些什么 .L4:它仍在检查对象的类型,现在它只是 cmov0(rdi)或解除引用之前的对象地址。因此它已明确保留了null访问权限,并且必须做很多工作才能实现。应该删除所有代码以及随后的代码 add 应该只是 add eax, DWORD PTR [rdx+4]。正在做 手动 似乎工作,代码几乎是最佳的。 - BeeOnRope


答案:


你几乎和你的工会在一起。您可以使用标记的联合(添加 if 在你的循环中区分)或a std::variant (它引入了一种双重调度方式 std::find 从中获取对象)这样做。在这两种情况下,您都没有对动态存储进行分配,因此可以保证数据的位置。
无论如何,正如您所看到的,在任何情况下,您都可以使用普通直接调用替换额外级别的间接(虚拟调用)。你需要 抹去 某种类型的类型(多态性只不过是一种类型的擦除,想到它),你不能直接从一个擦除的对象中获取 简单 呼叫。 if需要额外的电话来填补额外的间接水平。

这是一个使用的例子 std::variant 和 std::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);
}

5
2017-10-09 05:24



标记联合的问题在于:(a)标记可能占用大量空间(通常比对齐所预期的要多),但对象已经存在 知道 他们自己的类型所以它有点多余。这也意味着删除 virtual 呼叫的性质(或离开它并且遭受双倍的成本),这可能不太好,因为这些对象可能用于其他更正常的场景,其中 virtual基于多态性是必需的。 - BeeOnRope
@BeeOnRope其实没有。因为你知道确切的类型,编译器可以虚拟化调用。你可以留下虚拟方法。也, std::variant 是一种编译时,类型安全的标记联合等价但它应该占用较少。试试吧 std::visit 在迭代时。 - skypjack
部分是 - 如果它被宣布 final。否则编译器至少可以 推测 对其进行去虚拟化(即,内联由类型检查保护的已知实现)。可能只是使用 final 没关系......我得看看 std::variant。 - BeeOnRope
是的,我想要两个:)。值得注意的是,如果您只是连续存储对象(在固定大小的字段中)并转换为当前实现,那么它们实际上“正常工作” B *。当然它是UB,但是在没有额外的判别力的情况下,基本上可以做到这一点。所以我可以用UB或者在装配中吃蛋糕,我的问题主要是我们是否可以用C ++来实现。我想我并不太担心虚拟方法的成本,但我想要连续存储,以便操作说 B::common 可以是有效的,甚至可能是矢量化的。 - BeeOnRope
据我所知 std::variant 在这里没有任何魔力:它也是 嵌入一​​个领域 跟踪哪些替代品存在。所以我认为没有节省空间。 - BeeOnRope


我试着在没有内存开销的情况下实现你想要的东西

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 似乎更简单。


4
2017-10-09 16:01



嗯,这就像我想的那样(除了可能我想要一个可以存储N个对象的容器,而不仅仅是1个)。关于复制/移动构造函数的好处。要做到这一点,你需要以静态方式恢复原始类型(当然,除非你要求每个对象都声明它是自己的 virtual 类似于复制构造函数的方法来执行此操作)。断言的想法是检查a的表示 D * 作为一个 B *有点相同或类似的东西? - BeeOnRope
首先,我不确定 返回值的放置位置的新,但这似乎没有问题。其次,真正有问题的部分就像是 struct Derived : Base1, Base {}; 我们可以在哪里之间进行抵消 Base 和 Derived。 - Jarod42
“其次,真正有问题的那部分”这可以通过存储Base *(从D *中下载emplace()并在原地使用它或在get()中重新解释(使用)来轻松解决 - Massimiliano Janes
@MassimilianoJanes: “但我没有看到正确的方法来实现它没有内存开销”:在Base上添加指针的开销确实可以解决这个问题,但这会引入内存开销。内存开销, variant 允许复制。 - Jarod42


所以,这真的很难看,但是如果你没有使用多重继承或虚拟继承,那么 Derived * 在大多数实现中,将具有与a相同的位级值 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规则将禁止您的对象拥有虚拟析构函数,因为这些将被视为“非平凡”。您必须通过调用必须在每个派生类中定义的虚函数来销毁它们,然后直接调用析构函数。

但是,如果这段代码成功编译,那么如果你得到一个就没关系 Base * 来自 DerivedA 要么 DerivedB 你工会的成员。无论如何,它们将是相同的。

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


2
2017-10-17 14:32



谢谢。即使有了支票,这也是UB,但很可能在实践中工作,对吧? - BeeOnRope
@BeeOnRope - 是的。 UB,但可能在实践中工作。检查是为了测试最不可能的原因。我发现很难想象一个通过它们的实现无效。但我的想象力可能太有限了。我认为我的答案足够清楚了吗?你似乎足够知识填补空白。 - Omnifarious
@BeeOnRope - 我的'创建你自己的vtable'的想法不是UB,但它也只是一个旁边而不是这个答案的真正含义。 - Omnifarious
是的,很清楚! POD-with-function指针(或指向函数指针表的指针)是一个我似乎也在三角测量的想法。它消除了UB的各种原因,但保留了polymorphsim的许多好处(但不是全部)。 - BeeOnRope
对,这取决于你有多少“虚拟”功能。如果只有一个,那么vtable方法严格地说是性能更好 - 因为你遇到了额外的间接,并且对象的大小相同(它们有一个vtable指针或一个函数指针)。在两个函数中,它是空间与速度的权衡 - 直接嵌入的函数指针仍然可能更快,但对象更大。最终,当直接方法的不良局部性和缓存压力变得太大时,vtable方法甚至可能在速度上获胜。 - BeeOnRope


在CppCon 2017上有一个演讲,“运行时多态性 - 回归基础“,那讨论了做你喜欢的事情。 幻灯片 在github和谈话的视频是 在youtube上可用

演讲者的 试验 实现这一目标的库,“dyno”,也是 在github上


2
2017-10-18 19:01



谢谢,我给发言人发了一封电子邮件,询问他的视频是否可用。 - BeeOnRope
这不合适 一个答案。你至少应该努力总结谈话的内容。仅链接答案通常标记为SO。 - skypjack
@skypjack你是对的。当我得到答案时,我意识到了这一点。我最初只是将其作为评论发布,但它不认为是否适合评论的角色。我没有试图总结谈话的内容,部分原因是因为懒惰,部分是因为谈话的很多内容都在我脑海中。 - Colin Andrews
公平地说,它不仅仅是一个单一的链接。您可以将其解释为“您可以使用dynn库来执行此操作(这是一个有效的答案)”,这里是库的链接和有关该技术的讨论。当然,简单总结一下dyno是如何工作的会很好,但IMO它会满足答案的标准。 - BeeOnRope
当我将来有一些时间作简要总结时,我会尝试编辑我的答案。 - Colin Andrews


在我看来,你正在寻找一个 variant,这是一个安全访问的标记联盟。

c ++ 17有 std::variant。对于以前的版本,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());
}

1
2017-10-09 06:33



谢谢理查德。正如skypjack的回答中所讨论的那样,主要问题在于 std::variant 是(几乎可以肯定)它有一个隐藏的“判别”字段,它膨胀对象的大小(以类似于vtable指针的方式)。有时候不可能删除 virtual基于对象的多态性(例如,因为它用于需要该类型多态的其他上下文中),因此您需要支付两种方法的大小成本。什么时候 virtual 可以删除,这似乎是一种合理的方法。 - BeeOnRope
@BeeOnRope明白了。虽然在编译时知道类型的数量,但是在程序中的任何地方使用变量都不需要任何多态。 - Richard Hodges