Topik ini membahas dua perilaku berbahaya pada hierarki kelas, yaitu penghapusan objek melalui pointer kelas dasar tanpa destructor virtual dan object slicing pada penyalinan nilai. Materinya mencakup cara kerja destructor virtual, gejala yang muncul tanpanya, mekanisme pemotongan objek, serta teknik menghindarinya. Setelah membacanya, hierarki kelas dapat dirancang agar pembersihan sumber daya dan penyalinan data berlangsung utuh.
Destructor virtual pada kelas dasar#
Destructor virtual membuat penghapusan melalui pointer kelas dasar tetap menjalankan destructor kelas turunannya. Mekanismenya sama dengan fungsi virtual biasa, yaitu destructor yang dipilih ditentukan oleh tipe dinamis objek saat program berjalan, bukan oleh tipe pointer yang memegangnya. Karena destructor selalu dijalankan melalui tipe dasar pada pola polimorfik, sifat virtual pada destructor inilah yang menjamin pembersihan kelas turunan tetap terjadi. Program berikut memperagakan penghapusannya.
#include <iostream>
#include <string>
class Dokumen {
public:
Dokumen() {
std::cout << "Dokumen: memori internal disiapkan\n";
}
virtual ~Dokumen() {
std::cout << "Dokumen: memori internal dibebaskan\n";
}
};
class DokumenGambar : public Dokumen {
public:
DokumenGambar() {
std::cout << "Kanvas " << ukuran << " dialokasikan\n";
}
~DokumenGambar() override {
std::cout << "Kanvas " << ukuran << " dibebaskan\n";
}
private:
std::string ukuran = "800x600";
};
int main() {
Dokumen* dokumen = new DokumenGambar();
delete dokumen;
return 0;
}Dokumen: memori internal disiapkan
Kanvas 800x600 dialokasikan
Kanvas 800x600 dibebaskan
Dokumen: memori internal dibebaskanKeluaran tersebut memperlihatkan kedua destructor dijalankan meskipun delete ditulis pada pointer bertipe Dokumen*. Destructor kelas turunan dieksekusi lebih dahulu, kemudian destructor kelas dasar menyusul, sesuai urutan penghancuran yang kebalikan dari urutan konstruksinya. Kata kunci virtual pada destructor cukup ditulis sekali di kelas dasar, karena destructor seluruh kelas turunannya otomatis ikut virtual, sedangkan override pada contoh hanya berfungsi memastikan tanda tangan fungsinya memang cocok. Apabila ~Dokumen() tidak dinyatakan virtual, pemilihan destructor tidak lagi melihat tipe dinamis objek, dan inilah sumber masalah pada bagian berikutnya.
Gejala tanpa destructor virtual#
Tanpa destructor virtual, delete melalui pointer kelas dasar hanya menjalankan destructor kelas dasar, dan menurut standar bahasa, kondisi itu merupakan perilaku tak terdefinisi (undefined behavior). Potongan berikut memuat versi kelas Dokumen yang destructornya tidak virtual. Potongan ini sengaja tidak dijalankan karena programnya tidak sah menurut standar, dan gejalanya dijelaskan pada paragraf setelahnya.
// tidak-dijalankan
class Dokumen {
public:
~Dokumen() { std::cout << "Dokumen dibebaskan\n"; } // tidak virtual
};
class DokumenGambar : public Dokumen {
public:
~DokumenGambar() { std::cout << "Kanvas dibebaskan\n"; }
};
int main() {
Dokumen* dokumen = new DokumenGambar();
delete dokumen; // hanya ~Dokumen() yang dijalankan, sisanya tak terdefinisi
}Gejala ini jarang terlihat langsung pada saat kejadian. Destructor DokumenGambar tidak pernah dijalankan, sehingga kanvas 800x600 pada contoh sebelumnya tidak pernah dibebaskan dan memorinya bocor. Karena ukuran alokasi objek turunan dapat berbeda dari ukuran objek dasar, pelepasan memorinya juga tidak pasti benar, sehingga heap dapat rusak tanpa pesan error yang jelas. Program yang sama sering kali tampak berjalan normal selama berhari-hari sebelum gagal di tempat lain, dan keadaan itulah yang membuat jenis bug ini sulit dilacak.
Object slicing#
Object slicing adalah hilangnya bagian kelas turunan ketika objek turunan disalin ke objek kelas dasar secara nilai. Penyalinan semacam ini dilakukan compiler melalui copy constructor kelas dasar, dan copy constructor itu hanya mengenal subobjek milik kelas dasarnya, sehingga member tambahan milik kelas turunan tidak ikut tersalin. Kejadian ini muncul pada parameter fungsi yang diterima secara nilai, pada penugasan dasar = turunan;, serta pada penyimpanan objek turunan ke dalam container bertipe kelas dasar. Program berikut menyandingkan operasi melalui referensi dengan operasi melalui salinan.
#include <iostream>
#include <string>
#include <utility>
class Alat {
public:
explicit Alat(std::string n) : nama{std::move(n)} {}
virtual std::string deskripsi() const {
return "Alat " + nama;
}
std::string nama;
};
class Pensil : public Alat {
public:
Pensil(std::string n, int p) : Alat{std::move(n)}, panjang{p} {}
std::string deskripsi() const override {
return "Pensil " + nama + ", panjang " + std::to_string(panjang) + " cm";
}
int panjang;
};
void tampilkanReferensi(const Alat& alat) {
std::cout << "via referensi: " << alat.deskripsi() << "\n";
}
void tampilkanSalinan(Alat alat) {
std::cout << "via salinan : " << alat.deskripsi() << "\n";
}
int main() {
Pensil pensil("2B", 17);
std::cout << "objek asli : " << pensil.deskripsi() << "\n";
tampilkanReferensi(pensil);
tampilkanSalinan(pensil);
return 0;
}objek asli : Pensil 2B, panjang 17 cm
via referensi: Pensil 2B, panjang 17 cm
via salinan : Alat 2BBaris ketiga memperlihatkan akibat pemotongan. Salinan yang diterima tampilkanSalinan memang objek Alat sungguhan, sehingga member nama yang berisi "2B" tetap tersalin, sedangkan member panjang yang bernilai 17 hilang bersama bagian Pensil. Pemanggilan deskripsi() pada salinan juga diselesaikan ke versi kelas dasar, karena objek salinan tersebut memang bertipe Alat dan bukan Pensil yang diamati melalui referensi. Tidak ada peringatan compiler pada kejadian ini, sehingga pemotongan sering baru disadari ketika data yang hilang menimbulkan hasil keliru.
Menghindari slicing#
Slicing dihindari dengan tidak pernah menyalin objek turunan ke variabel kelas dasar secara nilai, melainkan mengoper dan menyimpannya melalui referensi atau pointer. Aturan praktisnya sederhana, yaitu parameter hierarki polimorfik ditulis const KelasDasar&, dan kumpulan objek campuran disimpan sebagai pointer ke kelas dasar. Dengan cara itu objek aslinya tetap utuh, sementara pemanggilan fungsi virtual tetap berjalan sesuai tipe dinamisnya. Program berikut menyimpan dua jenis alat berbeda ke dalam satu vector pointer dan menampilkan keduanya tanpa kehilangan apa pun.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
class Alat {
public:
explicit Alat(std::string n) : nama{std::move(n)} {}
virtual ~Alat() = default;
virtual std::string deskripsi() const {
return "Alat " + nama;
}
std::string nama;
};
class Pensil : public Alat {
public:
Pensil(std::string n, int p) : Alat{std::move(n)}, panjang{p} {}
std::string deskripsi() const override {
return "Pensil " + nama + ", panjang " + std::to_string(panjang) + " cm";
}
int panjang;
};
class Penggaris : public Alat {
public:
Penggaris(std::string n, int p) : Alat{std::move(n)}, panjang{p} {}
std::string deskripsi() const override {
return "Penggaris " + nama + ", panjang " + std::to_string(panjang) + " cm";
}
int panjang;
};
int main() {
Pensil pensil("2B", 17);
Penggaris penggaris("kayu", 30);
std::vector<Alat*> kotakAlat{&pensil, &penggaris};
for (const Alat* alat : kotakAlat) {
std::cout << alat->deskripsi() << "\n";
}
return 0;
}Pensil 2B, panjang 17 cm
Penggaris kayu, panjang 30 cmKedua baris keluaran menampilkan data milik masing-masing kelas turunan secara utuh, karena vector hanya menyimpan alamat objeknya dan tidak menyalin isinya. Pembandingnya, std::vector<Alat> akan memotong setiap objek pada saat push_back dilakukan. Polanya berlaku sama pada parameter fungsi, yaitu const Alat& menggantikan parameter nilai, sehingga pengiriman objek turunan antarfungsi hampir selalu melalui referensi. Untuk objek yang masa hidupnya dikelola secara dinamis, pemilikannya diperjelas dengan smart pointer pada modul RAII, sedangkan pola pointer di atas sudah cukup menunjukkan prinsip tersebut.
Kesalahan umum#
Kesalahan berikut paling sering muncul pada hierarki yang sudah dipakai secara polimorfik.
- Menghapus objek turunan melalui pointer kelas dasar yang destructornya tidak virtual. Destructor kelas turunan tidak dijalankan dan perilakunya tak terdefinisi, sehingga perbaikannya menambahkan
virtualpada destructor kelas dasar. - Menyimpan objek turunan secara nilai ke dalam container bertipe kelas dasar. Setiap elemen terpotong sejak dimasukkan, dan perbaikannya menyimpan pointer atau referensi ke kelas dasar.
- Mengira fungsi virtual pada kelas dasar otomatis membuat destructor ikut virtual. Sifat virtual tidak otomatis berlaku pada destructor, sehingga destructor tetap harus dinyatakan
virtualsecara tersendiri.
Ringkasan#
- Destructor virtual memastikan
deletemelalui pointer kelas dasar tetap menjalankan destructor kelas turunan, diikuti destructor kelas dasar. - Tanpa destructor virtual, penghapusan tersebut adalah undefined behavior yang gejalanya berupa kebocoran sumber daya dan kerusakan heap yang muncul terlambat.
- Object slicing terjadi pada penyalinan nilai dari kelas turunan ke kelas dasar, yaitu member milik kelas turunan hilang dan fungsi virtual jatuh ke versi kelas dasar.
- Slicing dihindari dengan mengoper objek melalui
const KelasDasar&dan menyimpannya sebagai pointer ke kelas dasar.
Referensi#
- Virtual destructors, virtual assignment, and overriding virtualization — LearnCpp — Penjelasan alasan destructor kelas dasar perlu virtual beserta contohnya.
- Object slicing — LearnCpp — Mekanisme pemotongan objek pada penyalinan nilai dan cara menghindarinya.
- Destructors — cppreference — Aturan destructor dalam standar, termasuk ketentuan destructor virtual.
- Virtual functions — cppreference — Mekanisme pemanggilan dinamis yang menjadi dasar perilaku destructor virtual.