Implementasi kelas dan antarmuka abstrak murni

27

Meskipun ini tidak wajib dalam standar C ++, tampaknya cara GCC misalnya, mengimplementasikan kelas induk, termasuk yang abstrak murni, adalah dengan memasukkan pointer ke tabel-v untuk kelas abstrak itu di setiap instance dari kelas yang bersangkutan .

Tentu saja ini menggembungkan ukuran setiap instance dari kelas ini dengan sebuah pointer untuk setiap kelas induk yang dimilikinya.

Tapi saya perhatikan bahwa banyak kelas dan struct C # memiliki banyak antarmuka orang tua, yang pada dasarnya adalah kelas abstrak murni. Saya akan terkejut jika setiap contoh mengatakan Decimal, membengkak dengan 6 pointer ke semua itu berbagai antarmuka.

Jadi jika C # melakukan antarmuka yang berbeda, bagaimana cara melakukannya, setidaknya dalam implementasi yang khas (saya mengerti standar itu sendiri mungkin tidak mendefinisikan implementasi seperti itu)? Dan apakah implementasi C ++ memiliki cara untuk menghindari ukuran objek mengasapi ketika menambahkan orang tua virtual murni ke kelas?

Clinton
sumber
1
Objek C # biasanya memiliki cukup banyak metadata yang terpasang, mungkin vtables tidak sebesar itu
max630
Anda bisa mulai dengan memeriksa kode yang dikompilasi dengan idl disassembler
max630
C ++ melakukan sebagian kecil dari "antarmuka" itu secara statis. Bandingkan IComparerdenganCompare
Caleth
4
GCC, misalnya, menggunakan penunjuk tabel vtable (penunjuk ke tabel vtable, atau VTT) per objek untuk kelas dengan beberapa kelas dasar. Jadi, setiap objek hanya memiliki satu pointer ekstra daripada koleksi yang Anda bayangkan. Mungkin itu berarti dalam prakteknya itu bukan masalah bahkan ketika kode dirancang dengan buruk dan ada hirarki kelas yang besar.
Stephen M. Webb
1
@ StephenM.Webb Sejauh yang saya mengerti dari jawaban SO ini , VTT hanya digunakan untuk memesan konstruksi / penghancuran dengan warisan virtual. Mereka tidak berpartisipasi dalam pengiriman metode dan tidak berakhir menghemat ruang di objek itu sendiri. Karena C ++ upcast secara efektif melakukan pengirisan objek, tidak mungkin untuk meletakkan pointer vtable di tempat lain kecuali di objek (yang untuk MI menambahkan pointer vtable di tengah objek). Saya memverifikasi dengan melihat g++-7 -fdump-class-hierarchyoutput.
amon

Jawaban:

35

Dalam implementasi C # dan Java, objek biasanya memiliki satu pointer ke kelasnya. Ini dimungkinkan karena mereka adalah bahasa warisan tunggal. Struktur kelas kemudian berisi vtable untuk hierarki warisan tunggal. Tetapi memanggil metode antarmuka memiliki semua masalah warisan ganda juga. Ini biasanya diselesaikan dengan menempatkan vtables tambahan untuk semua antarmuka yang diimplementasikan ke dalam struktur kelas. Ini menghemat ruang dibandingkan dengan implementasi virtual inheritance pada C ++, tetapi membuat pengiriman metode antarmuka lebih rumit - yang sebagian dapat dikompensasi dengan caching.

Misalnya dalam OpenJDK JVM, setiap kelas berisi array vtable untuk semua antarmuka yang diimplementasikan (antarmuka vtable disebut itable ). Ketika suatu metode antarmuka dipanggil, array ini dicari secara linear untuk mengetahui kemungkinan antarmuka itu, maka metode tersebut dapat dikirim melalui yang dapat dijalankan. Caching digunakan sehingga setiap situs panggilan mengingat hasil dari pengiriman metode, jadi pencarian ini hanya harus diulang ketika tipe objek konkret berubah. Kodesemu untuk pengiriman metode:

// 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
}

(Bandingkan kode asli di interpreter OpenJDK HotSpot atau kompiler x86 .)

C # (atau lebih tepatnya, CLR) menggunakan pendekatan terkait. Namun, di sini itables tidak berisi pointer ke metode, tetapi adalah slot map: mereka menunjuk ke entri dalam tabel utama kelas. Seperti halnya Java, harus mencari yang benar benar hanya skenario terburuk, dan diharapkan bahwa caching di situs panggilan dapat menghindari pencarian ini hampir selalu. CLR menggunakan teknik yang disebut Virtual Stub Dispatch untuk menambal kode mesin yang dikompilasi JIT dengan strategi caching yang berbeda. Kodesemu:

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
}

Perbedaan utama dengan pseudocode OpenJDK adalah bahwa dalam OpenJDK setiap kelas memiliki array dari semua antarmuka yang diimplementasikan secara langsung atau tidak langsung, sedangkan CLR hanya menyimpan array peta slot untuk antarmuka yang diimplementasikan secara langsung di kelas tersebut. Karena itu, kita perlu menjalankan hierarki warisan ke atas hingga peta slot ditemukan. Untuk hierarki warisan yang dalam, ini menghasilkan penghematan ruang. Ini sangat relevan dalam CLR karena cara bagaimana generik diimplementasikan: untuk spesialisasi generik, struktur kelas disalin dan metode dalam tabel utama dapat digantikan oleh spesialisasi. Peta slot terus menunjuk pada entri vtable yang benar dan karenanya dapat dibagikan di antara semua spesialisasi generik suatu kelas.

Sebagai catatan akhir, ada lebih banyak kemungkinan untuk mengimplementasikan pengiriman antarmuka. Alih-alih menempatkan pointer vtable / itable di objek atau dalam struktur kelas, kita bisa menggunakan pointer lemak ke objek, yang pada dasarnya adalah (Object*, VTable*)sepasang. Kekurangannya adalah ini menggandakan ukuran pointer dan bahwa upcast (dari tipe beton ke tipe antarmuka) tidak gratis. Tetapi lebih fleksibel, memiliki sedikit tipuan, dan juga berarti bahwa antarmuka dapat diimplementasikan secara eksternal dari suatu kelas. Pendekatan terkait digunakan oleh antarmuka Go, ciri-ciri Rust, dan typeclasses Haskell.

Referensi dan bacaan lebih lanjut:

  • Wikipedia: Caching sebaris . Membahas pendekatan caching yang dapat digunakan untuk menghindari pencarian metode yang mahal. Biasanya tidak diperlukan untuk pengiriman berbasis vtable, tetapi sangat diinginkan untuk mekanisme pengiriman yang lebih mahal seperti strategi pengiriman antarmuka di atas.
  • OpenJDK Wiki (2013): Panggilan Antarmuka . Membahas itables.
  • Pobar, Neward (2009): SSCLI 2.0 Internal. Bab 5 buku ini membahas peta slot dengan sangat rinci. Tidak pernah dipublikasikan tetapi disediakan oleh penulis di blog mereka . The PDF Link sejak pindah. Buku ini mungkin tidak lagi mencerminkan keadaan CLR saat ini.
  • CoreCLR (2006): Pengiriman rintisan virtual . Dalam: Book Of The Runtime. Membahas peta slot dan caching untuk menghindari pencarian mahal.
  • Kennedy, Syme (2001): Desain dan Implementasi Generik untuk .NET Common Language Runtime . ( Tautan PDF ). Membahas berbagai pendekatan untuk mengimplementasikan obat generik. Generik berinteraksi dengan pengiriman metode karena metode mungkin dikhususkan sehingga vtables mungkin harus ditulis ulang.
amon
sumber
Terima kasih @amon jawaban hebat menantikan detail tambahan baik tentang bagaimana Jawa dan CLR mencapai ini!
Clinton
@Clinton Saya memperbarui posting dengan beberapa referensi. Anda juga dapat membaca kode sumber VM, tetapi saya merasa sulit untuk mengikuti. Referensi saya agak tua, jika Anda menemukan sesuatu yang lebih baru saya akan sangat tertarik. Jawaban ini pada dasarnya adalah kutipan dari catatan saya telah tergeletak untuk posting blog, tetapi saya tidak pernah sempat mempublikasikannya: /
amon
1
callvirtAKA CEE_CALLVIRTdi CoreCLR adalah instruksi CIL yang menangani metode antarmuka panggilan, jika ada yang ingin membaca lebih lanjut tentang bagaimana runtime menangani pengaturan ini.
jrh
Perhatikan bahwa callopcode digunakan untuk staticmetode, yang menarik callvirtdigunakan bahkan jika kelasnya sealed.
jrh
1
Re, "objek [C #] biasanya memiliki satu pointer ke kelasnya ... karena [C # adalah] bahasa warisan tunggal." Bahkan di C ++, dengan semua potensinya untuk web kompleks dari tipe multiply-inherited, Anda masih hanya diizinkan untuk menentukan satu tipe pada titik di mana program Anda membuat instance baru. Secara teori, dimungkinkan untuk merancang kompiler C ++ dan pustaka run-time sedemikian rupa sehingga tidak ada instance kelas yang pernah membawa lebih dari satu nilai pointer RTTI.
Solomon Slow
2

Tentu saja ini menggembungkan ukuran setiap instance dari kelas ini dengan sebuah pointer untuk setiap kelas induk yang dimilikinya.

Jika dengan 'kelas induk' yang Anda maksud adalah 'kelas dasar' maka ini bukan kasus di gcc (atau saya harapkan dalam kompiler lain).

Dalam kasus C berasal dari B berasal dari A di mana A adalah kelas polimorfik, turunan C akan memiliki tepat satu vtable.

Compiler memiliki semua informasi yang diperlukan untuk menggabungkan data dalam A's vtable ke B's dan B's ke C's.

Berikut ini sebuah contoh: https://godbolt.org/g/sfdtNh

Anda akan melihat bahwa hanya ada satu inisialisasi dari vtable.

Saya telah menyalin output rakitan untuk fungsi utama di sini dengan anotasi:

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

Sumber lengkap untuk referensi:

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);
}
Richard Hodges
sumber
Jika kita mengambil contoh di mana subclass mewarisi langsung dari dua kelas dasar seperti class Derived : public FirstBase, public SecondBasemaka mungkin ada dua vtable. Anda dapat menjalankan g++ -fdump-class-hierarchyuntuk melihat tata letak kelas (juga ditampilkan di posting blog saya yang tertaut). Godbolt kemudian menunjukkan kenaikan pointer tambahan sebelum panggilan untuk memilih tabel ke-2.
amon