Pada Chapter Inheritance, method warisan dipilih oleh compiler berdasarkan tipe yang tertulis di kode — bukan berdasarkan isi object yang sedang berjalan. Untuk banyak kasus itu cukup, tetapi polymorphism yang sesungguhnya menuntut lebih: ketika sebuah kode menerima object lewat pointer atau referensi dasar, ia seharusnya menjawab “versi siapa yang benar-benar ada di sana?”. Keyword virtual adalah jawaban C++ untuk pertanyaan itu. Satu kata ini mengubah pemilihan method dari saat kompilasi (static binding) menjadi saat program berjalan (dynamic dispatch), dan menjadi fondasi seluruh polimorfisme runtime yang akan kita bangun di empat chapter berikutnya.
Masalah: Versi yang Salah Terpilih #
Mari kita lihat dulu apa yang terjadi tanpa virtual. Program berikut punya hierarki Hewan — Kucing dan Anjing yang masing-masing menulis ulang bersuara(), serta fungsi yang menerima object lewat referensi dasar:
#include <iostream>
class Hewan {
public:
void bersuara() const { // TANPA virtual
std::cout << "Hewan mengeluarkan suara\n";
}
};
class Kucing : public Hewan {
public:
void bersuara() const {
std::cout << "Meong\n";
}
};
class Anjing : public Hewan {
public:
void bersuara() const {
std::cout << "Guk guk\n";
}
};
// Fungsi menerima object lewat referensi dasar
void perkenalkan(const Hewan& h) {
h.bersuara(); // compiler memilih versi Hewan — tanpa melihat isi h
}
int main() {
Kucing kucing;
Anjing anjing;
perkenalkan(kucing); // harapan kita: Meong
perkenalkan(anjing); // harapan kita: Guk guk
return 0;
}Output yang Dihasilkan:
Hewan mengeluarkan suara
Hewan mengeluarkan suaraKeduanya menjawab “Hewan mengeluarkan suara” — kucing kita kehilangan suaranya. Penyebabnya: tanpa virtual, compiler mengikat pemanggilan h.bersuara() pada tipe yang tertulis (const Hewan&), bukan pada object yang sesungguhnya dirujuk. Binding semacam ini disebut static binding, dan terjadi sekali di waktu kompilasi. Compiler bekerja sebagaimana aturan bahasa: ia tidak salah, hanya saja aturan default-nya bukan yang kita maksud.
Solusi: Satu Kata virtual #
Perbaikannya minimal — tambahkan virtual pada deklarasi method di class dasar:
#include <iostream>
class Hewan {
public:
virtual void bersuara() const { // virtual: pilih versi saat program jalan
std::cout << "Hewan mengeluarkan suara\n";
}
};
class Kucing : public Hewan {
public:
void bersuara() const {
std::cout << "Meong\n";
}
};
class Anjing : public Hewan {
public:
void bersuara() const {
std::cout << "Guk guk\n";
}
};
// Fungsi menerima object lewat referensi dasar
void perkenalkan(const Hewan& h) {
h.bersuara(); // saat runtime: ikuti jenis object yang sesungguhnya
}
int main() {
Kucing kucing;
Anjing anjing;
perkenalkan(kucing); // sekarang: Meong
perkenalkan(anjing); // sekarang: Guk guk
Hewan* p = &kucing; // pointer dasar menunjuk object turunan
p->bersuara(); // tetap Meong
p = &anjing;
p->bersuara(); // tetap Guk guk
return 0;
}Output yang Dihasilkan:
Meong
Guk guk
Meong
Guk gukMari kita bedah:
- Baris 5 (
virtual void bersuara()): satu-satunya perubahan pada class dasar.virtualmenyatakan “pemanggilan method ini lewat pointer/referensi dasar harus mengikuti jenis object sebenarnya”. Cukup di dasar; versi turunannya otomatis ikut virtual tanpa menulis ulang kata itu. - Baris 26 (
h.bersuara()): baris kode yang sama persis dengan versi tanpa virtual, tetapi kini ia ditunda keputusannya sampai runtime. Yang dicatat compiler bukan “panggilHewan::bersuara”, melainkan “cari versi yang cocok pada object yang ditunjuk”. - Baris 30-34 (referensi dasar): kedua pemanggilan
perkenalkan()kini menghasilkan suara yang benar —MeonglaluGuk guk. Referensi dasar menyembunyikan jenis asli, dan dynamic dispatch menyingkirkannya. - Baris 36-39 (pointer dasar): satu pointer
Hewan*menunjuk dua object berbeda di dua waktu; setiap pemanggilanp->bersuara()menjawab sesuai isi pointer saat itu. Pola inilah yang membuat satu kode bisa melayani banyak jenis tanpaif/switchyang membedakan jenis.
Mekanisme di Balik Layar: vtable #
Bagaimana compiler mewujudkan keputusan yang ditunda? Untuk setiap class yang punya virtual function, compiler menyusun satu tabel statis bernama vtable (virtual table) — daftar alamat virtual function versi class itu. Setiap object dari class tersebut lalu membawa satu pointer tersembunyi (vptr) yang menunjuk vtable class-nya. Saat h.bersuara() dijalankan, program membaca vptr pada object, lompat ke baris tabel yang sesuai, dan memanggil alamat yang ditemukan di sana:
Mekanisme inilah yang membuat keputusan mengikuti object, bukan tipe penulisan: object Kucing membawa vptr ke vtable milik Kucing, sehingga apapun yang menunjuknya, jalur panggilan selalu berakhir di Kucing::bersuara(). Bayarannya ringan tapi nyata — satu lompatan ekstra per pemanggilan, ditambah satu pointer per object untuk vptr — dan karena keputusan baru diambil saat berjalan, compiler kehilangan kesempatan meng-inline panggilan tersebut. Untuk hampir semua program, biaya itu tidak terasa; kecuali di inner loop yang sangat panas, keramahan desain biasanya jauh lebih berharga.
Perhatikan satu batasan penting: dynamic dispatch hanya bekerja lewat pointer atau referensi. Memanggil h.bersuara() pada object Hewan biasa yang disalin by value tetap menjalankan versi miliknya sendiri — salinan memang hanya punya bagian dasar, fenomena yang akan kita telisik pada Chapter Object Slicing. Sebaliknya, pemanggilan langsung pada object konkrit (kucing.bersuara()) bisa diselesaikan compiler saat kompilasi tanpa menyentuh vtable sama sekali.
Chapter berikutnya menajamkan cerita ini: bagaimana menulis versi virtual di turunan dengan benar, keyword override yang menangkap kesalahan ketik sebelum sempat menjadi bug, dan final yang menutup gerbang pewarisan.
Rujukan resmi: Virtual specifier (cppreference) , Derived classes (cppreference) .