Virtual function

Dynamic dispatch, override, final, dan mental model vtable.

Daftar isi

Topik ini membahas virtual function, yaitu member function yang dipilih berdasarkan tipe sebenarnya dari objek saat program berjalan, bukan berdasarkan tipe yang tertulis pada kode pemanggilnya. Materinya mencakup dynamic dispatch melalui reference dan pointer kelas dasar, kata kunci override dan final, serta mental model vtable yang mendasari pemanggilan tersebut. Setelah membacanya, pembaca dapat membangun hierarki polimorfik yang perilakunya benar tanpa mengetahui tipe konkret objeknya.

Dynamic dispatch lewat kelas dasar#

Pemanggilan member function biasa diselesaikan saat kompilasi, yaitu compiler memilih fungsi berdasarkan tipe yang tampak pada ekspresi pemanggilnya, yang disebut tipe statis. Cara pemilihan ini menjadi kendala pada kode yang mengoperasikan objek melalui kelas dasar, karena semua objek diperlakukan sesuai tipe statisnya tanpa memperhatikan bagian turunannya. Member function yang dinyatakan virtual mengubah aturan itu, yaitu fungsi dipilih dari tipe sebenarnya milik objek, yang disebut tipe dinamis, saat program berjalan. Mekanisme ini disebut dynamic dispatch. Program berikut menempatkan keduanya berdampingan agar perbedaannya terlihat pada keluaran yang sama.

cpp bentuk-dispatch.cpp
#include <iostream>
#include <string>
#include <vector>

class Bentuk {
public:
    std::string deskripsi() const {
        return "bentuk tanpa nama";
    }

    virtual double luas() const {
        return 0.0;
    }
};

class PersegiPanjang : public Bentuk {
public:
    PersegiPanjang(double p, double l) : panjang{p}, lebar{l} {}

    std::string deskripsi() const {
        return "persegi panjang";
    }

    double luas() const override {
        return panjang * lebar;
    }

private:
    double panjang;
    double lebar;
};

class Lingkaran : public Bentuk {
public:
    explicit Lingkaran(double r) : radius{r} {}

    std::string deskripsi() const {
        return "lingkaran";
    }

    double luas() const override {
        return 3.14159265358979 * radius * radius;
    }

private:
    double radius;
};

void laporkan(const Bentuk& b) {
    std::cout << b.deskripsi() << " | luas = " << b.luas() << "\n";
}

int main() {
    PersegiPanjang p{4.0, 3.0};
    Lingkaran l{2.0};

    laporkan(p);
    laporkan(l);

    std::vector<Bentuk*> daftar{&p, &l};
    for (const Bentuk* b : daftar) {
        std::cout << "Via pointer: " << b->deskripsi() << " | luas = " << b->luas() << "\n";
    }
    return 0;
}
text Keluaran
bentuk tanpa nama | luas = 12
bentuk tanpa nama | luas = 12.5664
Via pointer: bentuk tanpa nama | luas = 12
Via pointer: bentuk tanpa nama | luas = 12.5664

Kedua kelas turunan mendefinisikan ulang deskripsi dan luas, tetapi kedua fungsi itu berperilaku berbeda. Panggilan b.luas() pada parameter const Bentuk& menghasilkan luas masing-masing bentuk karena luas dinyatakan virtual di kelas dasar, sehingga pemilihannya mengikuti tipe dinamis objek yang dirujuk. Sebaliknya, b.deskripsi() selalu menghasilkan versi kelas dasar karena deskripsi tidak virtual, sehingga compiler menyelesaikannya dari tipe statis Bentuk saja. Definisi ulang pada kelas turunan tetap ada, tetapi tidak pernah tercapai melalui reference maupun pointer kelas dasar. Kesimpulannya, perilaku polimorfik hanya dimiliki member function yang dirancang untuk itu sejak kelas dasarnya.

override dan final#

Kata kunci override ditulis pada member function kelas turunan yang sengaja mendefinisikan ulang virtual function kelas dasarnya. Kata itu tidak mengubah perilaku program, tetapi memerintahkan compiler untuk memeriksa kecocokan tanda tangan fungsi (signature) dengan sebuah virtual function di kelas dasar. Tanpa pemeriksaan itu, perbedaan kecil pada tipe parameter menghasilkan fungsi baru yang terpisah, bukan definisi ulang, dan program tetap lolos kompilasi. Program berikut memakai override pada setiap definisi ulang dan menambahkan final yang menutup hierarkinya.

cpp alat-tulis-final.cpp
#include <iostream>
#include <string>
#include <vector>

class AlatTulis {
public:
    virtual void tulis(const std::string& teks) const {
        std::cout << "AlatTulis menulis: " << teks << "\n";
    }
};

class Pensil : public AlatTulis {
public:
    void tulis(const std::string& teks) const override {
        std::cout << "Pensil menulis: " << teks << "\n";
    }
};

class PensilMekanik final : public Pensil {
public:
    void tulis(const std::string& teks) const override {
        std::cout << "Pensil mekanik menulis: " << teks << "\n";
    }
};

int main() {
    Pensil p;
    PensilMekanik pm;

    std::vector<const AlatTulis*> daftar{&p, &pm};
    for (const AlatTulis* a : daftar) {
        a->tulis("latihan C++");
    }
    return 0;
}
text Keluaran
Pensil menulis: latihan C++
Pensil mekanik menulis: latihan C++

Kata final memiliki dua letak pemakaian. Ditulis pada nama kelas, seperti class PensilMekanik final, kata itu melarang kelas mana pun mewarisinya, dan percobaan menurunkannya ditolak compiler. Ditulis pada member function, kata itu melarang definisi ulang berikutnya, sedangkan pewarisan kelasnya tetap dibolehkan. Contoh penolakan compiler pada kedua bentuknya diperlihatkan fragmen berikut.

cpp Contoh: override dan final masing-masing menangkap kesalahan
// tidak-dijalankan (tanda tangan berbeda dari tulis di AlatTulis, sehingga kata override ditolak compiler)
class Spidol : public AlatTulis {
public:
    void tulis(int teks) const override {}
};

// tidak-dijalankan (PensilMekanik ditandai final, sehingga kelas turunannya ditolak compiler)
class PensilKayu : public PensilMekanik {};

Model vtable#

Pemilihan fungsi saat program berjalan memerlukan tempat menyimpan daftarnya, dan mental modelnya disebut vtable. Setiap kelas yang memiliki virtual function memperoleh satu tabel statis berisi pointer ke implementasi virtual function milik kelasnya. Setiap objek dari kelas itu menyimpan satu pointer tersembunyi yang menunjuk ke tabel kelasnya, dan pointer itu diisi oleh constructor. Dengan demikian, bagian PersegiPanjang dari sebuah objek menunjuk tabel milik PersegiPanjang.

Saat b->luas() dipanggil melalui pointer Bentuk*, program membaca pointer tabel milik objek tersebut, mengambil entri fungsi luas, dan memanggil alamat yang tertulis di situ. Tipe dinamis ikut menentukan hasil karena tabel yang dibaca adalah tabel milik kelas sebenarnya dari objek tersebut.

Mekanisme ini menjelaskan biayanya. Setiap pemanggilan virtual function menambah satu lompatan alamat melalui tabel, dan compiler umumnya tidak dapat menyisipkan badan fungsi langsung di tempat pemanggilan karena implementasi yang benar baru diketahui saat program berjalan. Objek juga membawa satu pointer tambahan untuk tabelnya. Biaya tersebut relatif kecil untuk sebagian besar program, tetapi aturan praktisnya tetap berlaku. Member virtual hanya ditulis pada fungsi yang memang dirancang untuk didefinisikan ulang, sedangkan fungsi yang tidak memerlukan pemilihan saat berjalan dibiarkan non-virtual agar compiler bebas mengoptimalkannya.

Kesalahan umum#

Empat kesalahan berikut paling sering muncul saat virtual function dipraktikkan.

  • Mendefinisikan ulang fungsi dengan tanda tangan yang berbeda lalu mengharapkan pemanggilannya berpindah. Fungsi berbeda tanda tangan merupakan fungsi yang terpisah, bukan definisi ulang, sehingga panggilan lewat kelas dasar tetap menjalankan versi lamanya. Perbaikannya adalah mencocokkan tanda tangan fungsi turunan dan menandai definisi ulangnya dengan override.
  • Menghilangkan virtual di kelas dasar dan menaruhnya hanya di kelas turunan. Status virtual ditentukan oleh deklarasi paling atas, sehingga tanpa virtual di kelas dasar tidak ada dynamic dispatch sama sekali. Perbaikannya adalah menyatakan virtual pada kelas dasar tempat hierarki dimulai.
  • Memanggil virtual function dari constructor kelas dasar. Pada saat itu bagian kelas turunan dari objek belum dibangun, sehingga pemanggilan diselesaikan ke versi kelas dasar dan definisi ulangnya tidak berlaku. Perbaikannya adalah memindahkan pemanggilan semacam itu keluar dari constructor.
  • Mengira kata final pada member function menghentikan seluruh pewarisan kelasnya. Kata itu hanya menutup definisi ulang fungsi tersebut, sedangkan larangan pewarisan kelas ditulis pada nama kelasnya. Perbaikannya adalah meletakkan final pada tempat yang sesuai dengan larangan yang dimaksud.

Ringkasan#

  • Member function virtual dipilih berdasarkan tipe dinamis objek melalui reference atau pointer kelas dasar, sedangkan fungsi non-virtual dipilih dari tipe statisnya.
  • Kata override memerintahkan pemeriksaan compiler atas kecocokan tanda tangan, sedangkan final menutup definisi ulang fungsi atau pewarisan kelas.
  • Model vtable menyatakan setiap kelas virtual memiliki tabel pointer fungsi dan setiap objek membawa pointer ke tabel kelasnya, yang menjadi biaya satu pelompatan per pemanggilan.
  • Status virtual ditentukan kelas dasar, sehingga kata virtual ditulis di puncak hierarki dan definisi ulangnya ditandai override.

Referensi#