Mengapa kelas dasar perlu memiliki destruktor virtual di sini jika kelas turunan tidak mengalokasikan memori dinamis mentah?

12

Kode berikut menyebabkan kebocoran memori:

#include <iostream>
#include <memory>
#include <vector>

using namespace std;

class base
{
    void virtual initialize_vector() = 0;
};

class derived : public base
{
private:
    vector<int> vec;

public:
    derived()
    {
        initialize_vector();
    }

    void initialize_vector()
    {
        for (int i = 0; i < 1000000; i++)
        {
            vec.push_back(i);
        }
    }
};

int main()
{
    for (int i = 0; i < 100000; i++)
    {
        unique_ptr<base> pt = make_unique<derived>();
    }
}

Itu tidak masuk akal bagi saya, karena kelas yang diturunkan tidak mengalokasikan memori dinamis mentah, dan unique_ptr mendeallocate sendiri. Saya mendapatkan destruktor implisit basis kelas itu disebut bukan diturunkan, tapi saya tidak mengerti mengapa itu menjadi masalah di sini. Jika saya menulis destruktor eksplisit untuk diturunkan, saya tidak akan menulis apa pun untuk vec.

Ketidaktahuan inersia
sumber
4
Anda mengasumsikan destruktor hanya ada jika ditulis secara manual; asumsi ini salah: bahasa menyediakan ~derived()yang mendelegasikan ke destructor vec. Atau, Anda mengasumsikan bahwa unique_ptr<base> ptakan mengetahui destruktor yang diturunkan. Tanpa metode virtual, ini tidak dapat terjadi. Sementara unique_ptr dapat diberikan fungsi penghapusan yang merupakan parameter template tanpa representasi runtime, dan fitur itu tidak berguna untuk kode ini.
amon
Bisakah kita meletakkan kawat gigi di baris yang sama untuk membuat kode lebih pendek? Sekarang saya harus gulir.
laike9m

Jawaban:

14

Ketika kompiler pergi untuk mengeksekusi implisit delete _ptr;di dalam unique_ptrdestruktor (di mana _ptrpointer disimpan di unique_ptr), ia tahu persis dua hal:

  1. Alamat objek yang akan dihapus.
  2. Jenis pointer yang _ptr. Karena pointer ada di unique_ptr<base>, itu artinya _ptradalah tipe base*.

Ini yang diketahui kompiler. Jadi, mengingat bahwa itu menghapus objek tipe base, itu akan memanggil ~base().

Jadi ... di mana bagian di mana ia menghancurkan derviedobjek yang sebenarnya ditunjukkannya? Karena jika kompiler tidak tahu bahwa itu menghancurkan derived, maka ia tidak tahu derived::vec sama sekali , apalagi bahwa itu harus dihancurkan. Jadi, Anda telah merusak objek dengan membiarkan setengahnya tidak rusak.

Kompilator tidak dapat berasumsi bahwa base*yang dihancurkan sebenarnya adalah derived*; Bagaimanapun, mungkin ada sejumlah kelas yang berasal base. Bagaimana ia tahu jenis yang base*ditunjuk untuk jenis ini ?

Apa yang harus dilakukan oleh kompiler adalah mencari tahu destructor yang benar untuk dipanggil (ya, derivedmemiliki destructor. Kecuali Anda = deleteseorang destructor, setiap kelas memiliki destructor, baik Anda menulis satu atau tidak). Untuk melakukan ini, itu harus menggunakan beberapa informasi yang disimpan dalam baseuntuk mendapatkan alamat yang tepat dari kode destruktor untuk memohon, informasi yang ditetapkan oleh konstruktor dari kelas aktual. Maka harus menggunakan informasi ini untuk mengkonversi base*ke pointer ke alamat derivedkelas yang sesuai (yang mungkin atau mungkin tidak pada alamat yang berbeda. Ya, sungguh). Dan kemudian dapat memanggil destruktor itu.

Mekanisme yang baru saja saya jelaskan? Ini biasa disebut "pengiriman virtual": alias, hal yang terjadi setiap kali Anda memanggil fungsi yang ditandai virtualketika Anda memiliki pointer / referensi ke kelas dasar.

Jika Anda ingin memanggil fungsi kelas turunan ketika semua yang Anda miliki adalah pointer / referensi kelas dasar, fungsi itu harus dideklarasikan virtual. Destructors secara fundamental tidak berbeda dalam hal ini.

Nicol Bolas
sumber
0

Warisan

Inti dari warisan adalah untuk berbagi antarmuka dan protokol yang sama di antara banyak implementasi yang berbeda sehingga instance turunan kelas dapat diperlakukan secara identik dengan instance lain dari tipe turunan lainnya.

Dalam C + + warisan juga membawa serta rincian implementasi, menandai (atau tidak menandai) destructor sebagai virtual adalah salah satu detail implementasi tersebut.

Mengikat fungsi

Sekarang ketika fungsi, atau kasus khusus seperti konstruktor atau destruktor dipanggil, kompiler harus memilih implementasi fungsi yang dimaksud. Maka itu harus menghasilkan kode mesin yang mengikuti niat ini.

Cara paling sederhana untuk bekerja adalah dengan memilih fungsi pada waktu kompilasi dan mengeluarkan kode mesin yang cukup sehingga terlepas dari nilai apa pun, ketika potongan kode itu dijalankan, selalu menjalankan kode untuk fungsi tersebut. Ini berfungsi baik kecuali untuk warisan.

Jika kita memiliki kelas dasar dengan fungsi (bisa berupa fungsi apa pun, termasuk konstruktor atau destruktor) dan kode Anda memanggil fungsi di atasnya, apa artinya ini?

Mengambil dari contoh Anda, jika Anda menelepon initialize_vector()kompiler harus memutuskan apakah Anda benar-benar bermaksud memanggil implementasi yang ditemukan Base, atau implementasi yang ditemukan di Derived. Ada dua cara untuk memutuskan ini:

  1. Yang pertama adalah memutuskan itu karena Anda memanggil dari suatu Basejenis, maksud Anda implementasi di Base.
  2. Yang kedua adalah memutuskan bahwa karena tipe runtime dari nilai yang disimpan dalam nilai yang Basediketik bisa saja Base, atau Derivedbahwa keputusan untuk membuat panggilan, harus dibuat pada saat runtime ketika dipanggil (setiap kali dipanggil).

Kompiler pada titik ini bingung, kedua opsi sama-sama valid. Inilah saatnya virtualmasuk ke dalam campuran. Ketika kata kunci ini hadir, kompiler mengambil opsi 2 menunda keputusan antara semua implementasi yang mungkin sampai kode berjalan dengan nilai nyata. Ketika kata kunci ini tidak ada, kompiler memilih opsi 1 karena itu adalah perilaku normal.

Kompiler mungkin masih memilih opsi 1 jika ada panggilan fungsi virtual. Tetapi hanya jika itu dapat membuktikan bahwa ini selalu terjadi.

Konstruktor dan Destruktor

Jadi mengapa kita tidak menentukan Konstruktor virtual?

Lebih intuitif bagaimana kompiler akan memilih antara implementasi identik dari konstruktor untuk Deriveddan Derived2? Ini sangat sederhana, tidak bisa. Tidak ada nilai yang sudah ada sebelumnya dari mana kompiler dapat mempelajari apa yang sebenarnya dimaksudkan. Tidak ada nilai yang sudah ada sebelumnya karena itu adalah pekerjaan konstruktor.

Jadi mengapa kita perlu menentukan destruktor virtual?

Lebih intuitif bagaimana kompiler akan memilih antara implementasi untuk Basedan Derived? Mereka hanya panggilan fungsi, sehingga perilaku panggilan fungsi terjadi. Tanpa virtual destructor yang dideklarasikan, kompiler akan memutuskan untuk mengikat langsung ke Basedestructor terlepas dari jenis runtime nilai.

Dalam banyak kompiler, jika turunan tidak mendeklarasikan anggota data, atau mewarisi dari tipe lain, perilaku dalam ~Base()akan cocok, tetapi tidak dijamin. Itu akan bekerja murni karena kebetulan, sama seperti berdiri di depan penyembur api yang belum dinyalakan. Anda baik-baik saja untuk sementara waktu.

Satu-satunya cara yang benar untuk mendeklarasikan basis atau tipe antarmuka apa pun di C ++ adalah mendeklarasikan destruktor virtual, sehingga destruktor yang benar dipanggil untuk setiap instance dari hierarki tipe tipe itu. Ini memungkinkan fungsi dengan pengetahuan paling banyak dari instance untuk membersihkan instance itu dengan benar.

Kain0_0
sumber