纯抽象类和接口的实现


27

尽管在C ++标准中这不是强制性的,但例如,GCC似乎通过在每个相关类的实例中都包含指向该抽象类的v表的指针来实现父类(包括纯抽象类)的方式。

自然,这通过其具有的每个父类的指针使该类的每个实例的大小膨胀。

但是我注意到许多C#类和结构都有很多父接口,它们基本上是纯抽象类。如果对say的每个实例Decimal都充满了指向所有各种接口的6个指针,我会感到惊讶。

因此,如果C#确实使用不同的接口,至少在典型的实现中,它如何实现它们(我知道标准本身可能没有定义这种实现)?在将纯虚拟父代添加到类时,是否有任何C ++实现都可以避免对象大小膨胀?


1
C#对象通常附加了很多元数据,
与之

你可以用IDL拆装检查编译的代码开始
max630

C ++静态地完成了很大一部分“接口”。比较IComparerCompare
Caleth

4
例如,对于具有多个基类的类,GCC对每个对象使用一个vtable表指针(指向vtables表的指针,或VTT)。因此,每个对象只有一个额外的指针,而不是您正在想象的集合。也许这意味着在实践中,即使代码设计不佳且涉及庞大的类层次结构,这也不是问题。
Stephen M. Webb

1
@ StephenM.Webb据我从这个SO答案中了解到,VTT仅用于通过虚拟继承对构造/破坏进行排序。它们不参与方法分派,并且最终不会在对象本身中节省任何空间。由于C ++转换有效地执行对象切片,因此无法将vtable指针放置在对象中的任何其他位置(对于MI而言,这会将vtable指针添加到对象的中间)。我通过查看g++-7 -fdump-class-hierarchy输出进行了验证。
阿蒙(Amon)

Answers:


35

在C#和Java实现中,对象通常只有一个指向其类的指针。这是可能的,因为它们是单继承语言。然后,类结构包含单继承层次结构的vtable。但是调用接口方法也具有多重继承的所有问题。通常可以通过将所有已实现接口的附加vtable放入类结构中来解决此问题。与C ++中的典型虚拟继承实现相比,这节省了空间,但使接口方法的分派更加复杂-可以通过缓存部分地进行补偿。

例如,在OpenJDK JVM中,每个类都包含用于所有已实现接口的vtable数组(接口vtable称为itable)。调用接口方法时,将在该数组中线性搜索该接口的itable,然后可以通过该itable调度该方法。使用缓存是为了使每个调用站点都能记住方法分配的结果,因此仅当具体对象类型发生更改时才需要重复此搜索。用于方法分派的伪代码:

// Dispatch SomeInterface.method
Method const* resolve_method(
    Object const* instance, Klass const* interface, uint itable_slot) {

  Klass const* klass = instance->klass;

  for (Itable const* itable : klass->itables()) {
    if (itable->klass() == interface)
      return itable[itable_slot];
  }

  throw ...;  // class does not implement required interface
}

(比较OpenJDK HotSpot 解释器x86编译器中的真实代码。)

C#(或更准确地说,CLR)使用一种相关的方法。但是,这里的itables不包含指向方法的指针,而是插槽映射:它们指向类的主vtable中的条目。与Java一样,必须搜索正确的itable只是最坏的情况,并且可以预期,在调用站点进行缓存可以几乎始终避免这种搜索。CLR使用一种称为虚拟存根调度的技术,以使用不同的缓存策略来修补JIT编译的机器代码。伪代码:

Method const* resolve_method(
    Object const* instance, Klass const* interface, uint interface_slot) {

  Klass const* klass = instance->klass;

  // Walk all base classes to find slot map
  for (Klass const* base = klass; base != nullptr; base = base->base()) {
    // I think the CLR actually uses hash tables instead of a linear search
    for (SlotMap const* slot_map : base->slot_maps()) {
      if (slot_map->klass() == interface) {
        uint vtable_slot = slot_map[interface_slot];
        return klass->vtable[vtable_slot];
      }
    }
  }

  throw ...;  // class does not implement required interface
}

与OpenJDK伪代码的主要区别在于,在OpenJDK中,每个类都有一个数组,这些数组包含所有直接或间接实现的接口,而CLR仅保留该类中直接实现的接口的插槽映射数组。因此,我们需要将继承层次结构向上移动,直到找到插槽映射。对于深度继承层次结构,这可以节省空间。由于泛型的实现方式,这些在CLR中特别相关:对于泛型专门化,将复制类结构,并且主vtable中的方法可能会被专门化代替。插槽映射继续指向正确的vtable条目,因此可以在一个类的所有通用专长之间共享。

最后,实现接口调度还有更多的可能性。无需将vtable / itable指针放在对象或类结构中,我们可以使用指向对象的胖指针,它们基本上是(Object*, VTable*)一对。缺点是指针大小增加了一倍,并且上载(从具体类型到接口类型)不是免费的。但是它更灵活,间接性更少,也意味着可以从类的外部实现接口。Go接口,Rust特征和Haskell类型类使用了相关的方法。

参考资料和进一步阅读:

  • Wikipedia:内联缓存。讨论可用于避免昂贵的方法查找的缓存方法。基于vtable的分派通常不需要,但是对于像上述接口分派策略这样的更昂贵的分派机制非常理想。
  • OpenJDK Wiki(2013):接口调用。讨论实用性。
  • Pobar,Neward(2009):SSCLI 2.0 Internals。本书的第5章详细讨论了插槽图。从未出版过,但是由作者在他们的博客上提供。此后,PDF链接已移动。这本书可能不再反映CLR的当前状态。
  • CoreCLR(2006):虚拟存根调度。在:运行时手册。讨论插槽映射和缓存以避免昂贵的查找。
  • Kennedy,Syme(2001):. NET公共语言运行时的泛型设计和实现。(PDF链接)。讨论实现泛型的各种方法。泛型与方法分派交互,因为方法可能是专门的,所以可能必须重写vtables。

感谢@amon很棒的答案,期待有关Java和CLR如何实现此目标的更多详细信息!
克林顿

@克林顿我更新了一些参考资料。您也可以阅读VM的源代码,但是我发现很难遵循。我的参考文献有些陈旧,如果您发现更新的内容,我会很感兴趣。这个答案基本上是我在一个博客文章中闲逛的笔记的摘录,但是我从来没有发表过它:/
amon

1
callvirt如果有人想阅读有关运行时如何处理此设置的更多信息CEE_CALLVIRT,CoreCLR中的AKA 是处理调用接口方法的CIL指令。
jrh '18

请注意,call操作码用于static方法,有趣的callvirt是,即使该类是,也可以使用sealed
jrh

1
关于,“ [C#]对象通常具有指向其类的单个指针...因为[C#是一种单继承语言。” 即使在C ++中,它也具有处理多重继承类型的复杂Web的全部潜力,但仍然只允许在程序创建新实例时指定一种类型。从理论上讲,应该有可能设计一个C ++编译器和一个运行时支持库,从而使任何类实例都不会携带超过RTTI指针值的指针。
所罗门慢

2

自然,这通过其具有的每个父类的指针使该类的每个实例的大小膨胀。

如果用“父类”来表示“基类”,那么在gcc中就不是这种情况(我也不希望在任何其他编译器中使用)。

如果C的派生自B的派生自A,而A属于多态类,则C实例将恰好具有一个vtable。

编译器拥有将A的vtable中的数据合并为B的内容并将B的合并为C的所有信息。

这是一个示例:https : //godbolt.org/g/sfdtNh

您将看到只有一个vtable初始化。

我在这里用注释复制了main函数的程序集输出:

main:
        push    rbx

# allocate space for a C on the stack
        sub     rsp, 16

# initialise c's vtable (note: only one)
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for C+16

# use c    
        lea     rdi, [rsp+8]
        call    do_something(C&)

# destruction sequence through virtual destructor
        mov     QWORD PTR [rsp+8], OFFSET FLAT:vtable for B+16
        lea     rdi, [rsp+8]
        call    A::~A() [base object destructor]

        add     rsp, 16
        xor     eax, eax
        pop     rbx
        ret
        mov     rbx, rax
        jmp     .L10

完整参考资料来源:

struct A
{
    virtual void foo() = 0;
    virtual ~A();
};

struct B : A {};

struct C : B {

    virtual void extrafoo()
    {
    }

    void foo() override {
        extrafoo();
    }

};

int main()
{
    extern void do_something(C&);
    auto c = C();
    do_something(c);
}

如果我们取一个示例,其中,子类直接从两个基类继承喜欢class Derived : public FirstBase, public SecondBase然后可以有两个虚函数表。您可以运行g++ -fdump-class-hierarchy以查看类的布局(也显示在我的链接博客文章中)。然后,Godbolt将在调用之前显示一个额外的指针增量,以选择第二个vtable。
阿蒙
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.