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:

cpp
#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:

text
Hewan mengeluarkan suara
Hewan mengeluarkan suara

Keduanya 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:

cpp
#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:

text
Meong
Guk guk
Meong
Guk guk

Mari kita bedah:

  1. Baris 5 (virtual void bersuara()): satu-satunya perubahan pada class dasar. virtual menyatakan “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.
  2. Baris 26 (h.bersuara()): baris kode yang sama persis dengan versi tanpa virtual, tetapi kini ia ditunda keputusannya sampai runtime. Yang dicatat compiler bukan “panggil Hewan::bersuara”, melainkan “cari versi yang cocok pada object yang ditunjuk”.
  3. Baris 30-34 (referensi dasar): kedua pemanggilan perkenalkan() kini menghasilkan suara yang benar — Meong lalu Guk guk. Referensi dasar menyembunyikan jenis asli, dan dynamic dispatch menyingkirkannya.
  4. Baris 36-39 (pointer dasar): satu pointer Hewan* menunjuk dua object berbeda di dua waktu; setiap pemanggilan p->bersuara() menjawab sesuai isi pointer saat itu. Pola inilah yang membuat satu kode bisa melayani banyak jenis tanpa if/switch yang 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) .