Chapter sebelumnya menutup dengan satu pertanyaan yang menggantung: apa yang terjadi ketika object di heap dihapus lewat pointer class dasar? Jawaban singkatnya mengejutkan banyak pemula — bila destructor class dasar tidak virtual, destructor class turunan tidak dijalankan sama sekali, dan standar C++ menyebut situasi ini undefined behavior. Chapter ini mengupas mengapa hal itu terjadi, bagaimana satu kata virtual menyelesaikannya, dan aturan emas yang wajib melekat pada setiap class dasar polimorfik. Karena new dan delete berperan besar di sini, ingat konteksnya: keduanya adalah mekanisme alokasi memori dinamis yang pembahasan lengkapnya menunggu di Section Manajemen Memori; di chapter ini kita memakai pola paling sederhananya saja.
Perangkap: delete lewat Pointer Class Dasar #
Mari kita susun skenario yang sangat wajar. Class AlatMusik bersifat polimorfik — punya virtual function — dan class Gitar mewarisinya sambil menyiapkan resource di constructor-nya, misalnya memori yang diperoleh dari new. Object Gitar dibuat di heap, dan alamatnya ditampung pointer bertipe AlatMusik* — persis pola koleksi pointer dasar pada Chapter Polymorphism. Sampai di sini belum ada yang salah; masalah baru muncul ketika delete dieksekusi terhadap pointer itu, padahal destructor AlatMusik tidak ditandai virtual:
#include <iostream>
#include <string>
class AlatMusik {
public:
~AlatMusik() { // bukan virtual
std::cout << " [Dasar] destructor AlatMusik dijalankan\n";
}
virtual std::string nama() const {
return "AlatMusik";
}
};
class Gitar : public AlatMusik {
private:
int* senar;
public:
Gitar() : senar(new int[6]) {
std::cout << " [Gitar] resource 6 senar disiapkan\n";
}
~Gitar() { // tidak akan terpanggil!
delete[] senar;
std::cout << " [Gitar] resource senar dilepas\n";
}
std::string nama() const override {
return "Gitar";
}
};
int main() {
AlatMusik* a = new Gitar();
std::cout << "dipakai: " << a->nama() << "\n";
delete a; // destructor Gitar tidak dijalankan
std::cout << "program selesai\n";
return 0;
}Penyebabnya ada di cara compiler memilih destructor. Pernyataan delete a; membaca tipe static pointer a — yaitu AlatMusik* — lalu memanggil destructor berdasarkan tipe itu, persis seperti member function biasa yang tidak melewati vtable. Penanda virtual pada destructor-lah yang memindahkan pemanggilannya ke jalur dynamic dispatch; tanpanya, ~Gitar() yang berisi delete[] senar tidak pernah masuk daftar kandidat. Akibatnya resource yang disiapkan constructor turunan tidak pernah dilepas.
Standar C++ menyatakan baris delete semacam ini undefined behavior: ketika tipe static (AlatMusik) berbeda dari tipe dinamis (Gitar) dan destructor tipe static tidak virtual, standar tidak menjamin apa pun tentang hasilnya. Bahaya terbesarnya justru karena program sering tampak sehat — di satu platform destructor turunan terlewat tanpa gejala, di platform lain manajemen heap bisa terganggu hingga memunculkan perilaku yang sulit dilacak. Bila versi bermasalah di atas dijalankan pada mesin pengujian kita, hasilnya memang tampak wajar — tetapi perhatikan baris yang hilang:
[Gitar] resource 6 senar disiapkan
dipakai: Gitar
[Dasar] destructor AlatMusik dijalankan
program selesaiBaris [Gitar] resource senar dilepas tidak pernah tercetak: destructor turunan diabaikan, delete[] senar tidak dieksekusi, dan enam blok int tertahan sampai program berakhir. Output ini bukan janji — karena perilakunya undefined, platform lain boleh berperilaku berbeda. Yang tetap andal adalah dua sinyal di bawah ini. Pertama, compiler berusaha menolong dengan peringatan khusus:
warning: deleting object of polymorphic class type 'AlatMusik' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]Kedua, sanitizer AddressSanitizer — alat pemeriksa memori yang tersedia di toolchain kita — menangkap ketidakcocokan tipe saat delete:
==12568==ERROR: AddressSanitizer: new-delete-type-mismatch on 0x502000000010 in thread T0:
object passed to delete has wrong type:
size of the allocated type: 16 bytes;
size of the deallocated type: 8 bytes.
#0 ... in operator delete(void*, unsigned long)
#1 ... in main
...
SUMMARY: AddressSanitizer: new-delete-type-mismatchLaporan ini mengungkap mekanisme di balik layar dengan sangat jelas: object dialokasikan sebagai Gitar seukuran 16 byte, tetapi delete melepasnya sebagai AlatMusik seukuran 8 byte. Compiler menangani object itu seukuran class dasar — sebagian memori dan seluruh logika pelepasan turunan keluar dari perhitungan. Peringatan dan laporan semacam ini membantu, tetapi keduanya baru bekerja setelah kode salah ditulis; solusi yang benar ada di sisi desain.
Solusi: Jadikan Destructor Virtual #
Perbaikannya satu kata: tambahkan virtual pada deklarasi destructor class dasar. Sejak saat itu destructor ikut terdaftar di vtable, sehingga delete a; melakukan dynamic dispatch persis seperti pemanggilan virtual function biasa — compiler mencari tipe object yang sebenarnya dihapus, menjalankan destructor-nya, lalu merantai otomatis ke destructor class dasar. Program berikut adalah versi benar dari skenario yang sama, dan kali ini bisa kita uji penuh:
#include <iostream>
#include <string>
class AlatMusik {
public:
AlatMusik() {
std::cout << " [Dasar] constructor AlatMusik dijalankan\n";
}
virtual ~AlatMusik() { // virtual: kunci amannya delete lewat pointer dasar
std::cout << " [Dasar] destructor AlatMusik dijalankan\n";
}
virtual std::string nama() const {
return "AlatMusik";
}
};
class Gitar : public AlatMusik {
private:
std::string senar; // dalam program nyata: memori, file, atau koneksi
public:
explicit Gitar(const std::string& s) : senar(s) {
std::cout << " [Gitar] senar " << senar << " dipasang, resource disiapkan\n";
}
~Gitar() override { // override pada destructor: sah dan dianjurkan
std::cout << " [Gitar] resource senar " << senar << " dilepas\n";
}
std::string nama() const override {
return "Gitar";
}
};
int main() {
std::cout << "membuat Gitar di heap, ditampung pointer class dasar\n";
AlatMusik* a = new Gitar("nylon");
std::cout << "dipakai: " << a->nama() << "\n";
std::cout << "menghapus lewat pointer class dasar\n";
delete a; // aman: destructor AlatMusik virtual
std::cout << "program selesai\n";
return 0;
}Output yang Dihasilkan:
membuat Gitar di heap, ditampung pointer class dasar
[Dasar] constructor AlatMusik dijalankan
[Gitar] senar nylon dipasang, resource disiapkan
dipakai: Gitar
menghapus lewat pointer class dasar
[Gitar] resource senar nylon dilepas
[Dasar] destructor AlatMusik dijalankan
program selesaiMari kita bedah:
- Baris 10 (
virtual ~AlatMusik()): satu kata yang menentukan segalanya. Destructor kini terdaftar di vtable, sehingga penghapusan lewatAlatMusik*dapat mencapai tipe yang sebenarnya. Alternatif yang setara bila badannya kosong:virtual ~AlatMusik() = default;. - Baris 14-16 (
nama()): virtual function inilah yang menjadikanAlatMusikpolimorfik. Tanpa satu pun virtual function, tidak ada vtable dan tidak ada dynamic dispatch — skenario penghapusan lewat pointer dasar pun jarang bermakna. - Baris 24-26 (constructor
Gitar): resource disiapkan. Dua baris pertama output membuktikan urutan konstruksi yang sudah kita kenal: bagian dasar dibangun lebih dulu, baru badan constructor turunan dieksekusi. - Baris 28 (
~Gitar() override): destructor turunan boleh — dan sebaiknya — ditandaioverride. Penanda ini membuat compiler memeriksa keberadaan destructor virtual di class dasar; bila~AlatMusik()tidak virtual, kompilasi langsung ditolak dengan pesan “marked ‘override’, but does not override”. - Baris 39 (
AlatMusik* a = new Gitar("nylon")): object dibuat di heap, alamatnya ditampung pointer class dasar. Tipe pointerAlatMusik*tidak mengubah tipe object yang dialokasikan:GitartetapGitar. - Baris 42 (
delete a;): pemanggilan destructor kini lewat dynamic dispatch. Dua baris setelah teks “menghapus lewat pointer class dasar” membuktikan urutan rantainya: badan~Gitar()dieksekusi lebih dulu — resource senar dilepas — baru badan~AlatMusik(). Urutan ini konsisten dengan aturan destruksi kebalikan konstruksi dari Chapter Destructor. - Baris 43-44 (akhir program): tidak ada destructor tambahan yang muncul di akhir
main(), karena object satu-satunya sudah dirapikan tepat saatdeletedieksekusi — bukan saat scope berakhir.
Aturan Emas dan Batasnya #
Aturannya bisa dihafal satu kalimat: class yang dirancang sebagai class dasar polimorfik — punya minimal satu virtual function — wajib punya virtual destructor. Biayanya hampir nol: setiap class polimorfik sudah membawa vtable pointer, sehingga menambah virtual pada destructor tidak menambah ukuran object, hanya satu entri vtable. Dengan satu kata, seluruh lubang pada chapter ini tertutup — tanpa syarat tambahan apa pun di sisi pemanggil.
Batasnya juga penting dipegang: class yang tidak punya virtual function dan tidak dirancang diwarisi memang sengaja tidak punya virtual destructor. std::string, std::vector, dan std::optional adalah contohnya; ketiadaan virtual destructor di class pustaka standar adalah sinyal desain — “class ini tidak untuk dihapus lewat pointer dasar” — bukan kelalaian. Mewarisi class semacam itu secara public pun bukan kebiasaan yang disarankan.
Untuk ke depannya, aturan ini akan terus menemani kita: smart pointer seperti std::unique_ptr<AlatMusik> juga menghapus object lewat pointer class dasar, sehingga syarat yang sama berlaku — detailnya menanti di Section Manajemen Memori. Pointer dan reference ternyata menjaga identitas object turunan dengan setia. Pertanyaan yang tersisa: bagaimana bila object itu tidak diakses lewat pointer, melainkan disalin begitu saja ke variabel class dasar? Chapter berikutnya menunjukkan bahwa penyalinan by value memperlakukan identitas itu sama sekali berbeda.
Rujukan resmi: Destructors (cppreference) , Virtual specifier (cppreference) .