Впровадження чистих абстрактних класів та інтерфейсів


27

Хоча це не є обов'язковим у стандарті C ++, схоже, GCC, наприклад, реалізує батьківські класи, включаючи чисто абстрактні, шляхом включення покажчика на v-таблицю для цього абстрактного класу в кожній інстанції відповідного класу. .

Природно, це збільшує розмір кожного примірника цього класу вказівником для кожного батьківського класу, який він має.

Але я помітив, що у багатьох класах та структурах C # є багато батьківських інтерфейсів, які в основному є чистими абстрактними класами. Я був би здивований, якби кожен екземпляр скажімо Decimalбув роздутий 6 покажчиками на всі різні інтерфейси.

Отже, якщо C # робить інтерфейси інакше, як це робити, принаймні в типовій реалізації (я розумію, що сам стандарт може не визначати таку реалізацію)? І чи є у будь-яких реалізацій C ++ спосіб уникнути розмивання розміру об'єкта при додаванні чистих віртуальних батьків до класів?


1
В об’єктах C # зазвичай додається дуже багато метаданих, можливо, vtables не такі великі порівняно з цим
max630

ви можете почати з вивчення скомпільованого коду з
ідентифікатора idl

C ++ робить значну частину своїх "інтерфейсів" статично. Порівняйте IComparerзCompare
Caleth

4
Наприклад, GCC використовує вказівник vtable table (вказівник на таблицю vtables або VTT) на об'єкт для класів з кількома базовими класами. Отже, кожен об’єкт має лише один додатковий покажчик, а не колекцію, яку ви уявляєте. Можливо, це означає, що на практиці це не проблема, навіть якщо код погано розроблений і в ньому задіяна масивна ієрархія класів.
Стівен М. Уебб

1
@ StephenM.Webb Наскільки я зрозумів з цієї відповіді ТА, VTT використовуються лише для замовлення будівництва / знищення з віртуальною спадщиною. Вони не беруть участь у відправці методу і не закінчують економію простору в самому об'єкті. Оскільки оновлення C ++ ефективно виконують нарізку об'єктів, неможливо розмістити покажчик vtable в іншому місці, а не в об'єкті (що для MI додає vtable покажчики в середину об'єкта). Я перевірив, дивлячись на g++-7 -fdump-class-hierarchyрезультат.
амон

Відповіді:


35

У реалізаціях C # та Java об'єкти, як правило, мають один вказівник на його клас. Це можливо, оскільки вони є одномовними спадковими мовами. Потім структура класу містить vtable для ієрархії одного успадкування. Але виклик методів інтерфейсу також має всі проблеми багаторазового успадкування. Зазвичай це вирішується шляхом додавання додаткових vtables для всіх реалізованих інтерфейсів у структуру класу. Це економить простір порівняно з типовими реалізаціями віртуального успадкування в C ++, але ускладнює розсилку методів інтерфейсу - які можна частково компенсувати кешуванням.

Наприклад, у JVM OpenJDK кожен клас містить масив vtables для всіх реалізованих інтерфейсів (інтерфейс vtable називається 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
}

(Порівняйте реальний код в інтерпретаторі HotJS OpenJDK або компіляторі x86 .)

C # (а точніше, CLR) використовує пов'язаний підхід. Однак тут ітабелі не містять покажчиків на методи, а є картами слотів: вони вказують на записи в головному vtable класу. Як і у Java, пошук потрібних версій є найгіршим сценарієм, і очікується, що кешування на сайті виклику може уникнути цього пошуку майже завжди. CLR використовує техніку під назвою Virtual Stub Dispatch для того, щоб виправити 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, рисовими ознаками та класами типу Haskell.

Посилання та додаткове читання:

  • Вікіпедія: вбудоване кешування . Обговорюють підходи до кешування, які можна використовувати, щоб уникнути дорогого пошуку способу. Зазвичай не потрібна для відправки на основі Vtable, але дуже бажана для більш дорогих механізмів диспетчеризації, як описано вище.
  • OpenJDK Wiki (2013): інтерфейсні дзвінки . Обговорюйте ідентифікатори.
  • Pobar, Neward (2009): SSCLI 2.0 Internals. Глава 5 книги дуже детально розглядає карти слотів. Ніколи не публікувався, але авторами не був доступний у своїх блогах . Посилання PDF з цього моменту перейшло. Ця книга, ймовірно, більше не відображає поточний стан CLR.
  • CoreCLR (2006): віртуальна диспетчерська робота . В: Книга виконання. Обговорюйте карти слотів та кешування, щоб уникнути дорогих пошукових запитів.
  • Кеннеді, Сайм (2001): Розробка та впровадження загальної інформації для .NET Common Language Runtime . ( Посилання в PDF ). Обговорюють різні підходи до впровадження генерики. Генеріки взаємодіють з відправленням методу, оскільки методи можуть бути спеціалізованими, тому vtables, можливо, доведеться переписувати.

Дякую @amon чудовий відповідь з нетерпінням чекаю додаткових подробиць як про те, як Java і CLR досягають цього!
Клінтон

@Clinton Я оновив пост з деякими посиланнями. Ви також можете прочитати вихідний код віртуальних машин, але мені було важко слідувати. Мої посилання трохи старі, якщо ви знайдете щось нове, я б дуже зацікавився. Ця відповідь, як правило, є уривком записок, які я лежав навколо допису в блозі, але мені ніколи не доводилося публікувати його: /
amon

1
callvirtAKA CEE_CALLVIRTв CoreCLR - це інструкція CIL, яка обробляє методи виклику інтерфейсу, якщо хтось хоче прочитати більше про те, як час виконання обробляє цю установку.
jrh

Зауважте, що callопкод використовується для staticметодів, що цікаво callvirt, навіть якщо клас є sealed.
jrh

1
Повторно, "[C #] об'єкти зазвичай мають один вказівник на свій клас ... тому, що [C # - це] мова з однонаспадковуванням." Навіть у C ++, маючи весь свій потенціал для складних веб-сайтів множинно успадкованих типів, ви все одно можете вказати лише один тип у точці, де ваша програма створює новий екземпляр. Теоретично повинно бути спроектовано компілятор C ++ та бібліотеку підтримки робочого часу таким чином, щоб жоден екземпляр класу ніколи не мав RTTI більше, ніж один покажчик.
Соломон повільно

2

Природно, це збільшує розмір кожного примірника цього класу вказівником для кожного батьківського класу, який він має.

Якщо під "батьківським класом" ви маєте на увазі "базовий клас", то це не так у gcc (і я не очікую в будь-якому іншому компіляторі).

У випадку, коли C походить від B, походить від A, де A є поліморфним класом, екземпляр C матиме рівно один vtable.

У компілятора є вся необхідна інформація, щоб об'єднати дані в V vtable в B, а B в C.

Ось приклад: https://godbolt.org/g/sfdtNh

Ви побачите, що існує лише одна ініціалізація vtable.

Я скопіював тут збірний вихід для основної функції із примітками:

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може бути два vtables. Ви можете бігти, g++ -fdump-class-hierarchyщоб побачити макет класу (також показаний у моєму пов’язаному дописі в блозі). Тоді Godbolt показує додатковий приріст покажчика перед викликом, щоб вибрати другий vtable.
амон
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.