Chapter Virtual Destructors menunjukkan sisi baik pointer: selama object diakses lewat pointer atau reference, identitas aslinya tetap dikenali — delete lewat pointer dasar masih tahu bahwa object itu Gitar. Chapter ini menguji sisi sebaliknya: apa yang terjadi bila object turunan disalin by value ke variabel class dasar. Jawabannya adalah salah satu jebakan paling senyap di OOP C++: bagian turunan terpotong tanpa error, tanpa warning, dan program tetap berjalan dengan hasil yang hampir benar. Istilahnya object slicing — dan setelah chapter ini, kita akan mengenalinya langsung dari bentuk kodenya.

Cara Kerja Slicing #

Perhatikan pernyataan Dokumen salinan = asli; ketika asli bertipe DokumenGambar dan salinan bertipe Dokumen. Compiler memanggil copy constructor milik Dokumen — tipe dari variabel tujuan — dan copy constructor itu hanya menyalin member class dasar: judul. Data tambahan milik turunan tidak punya tempat untuk menumpang: ukuran salinan sudah dipatok persis seukuran Dokumen, sehingga lebar dan tinggi tidak muat di dalamnya. Penyalinan selesai dengan sempurna — hanya saja yang disalin memang seluruhnya bagian dasar.

Vtable pointer ikut mengalami nasib yang sama. Seperti dibahas pada Chapter Virtual Functions, vtable pointer adalah member tersembunyi penanda dynamic dispatch; saat object dasar dibangun, pointer itu diarahkan ke vtable milik Dokumen, bukan disalin dari vtable DokumenGambar. Akibatnya bersifat ganda: data turunan hilang, dan panggilan virtual pada salinan menjawab versi class dasar. salinan bukan DokumenGambar yang setengah jadi — ia adalah Dokumen yang sah dari ujung ke ujung.

Poin penting yang membedakan topik ini dari undefined behavior pada chapter sebelumnya: slicing bukan perilaku undefined. Standar C++ mendefinisikan hasilnya dengan jelas — sebuah object class dasar yang valid dan konsisten. Bahayanya berada di luar definisi bahasa: kehilangan data dan perilaku terjadi diam-diam, tanpa crash dan tanpa warning, sehingga kesalahan baru terasa jauh dari sumbernya.

Bukti dalam Program #

Program berikut membuktikan semuanya dalam satu main(): object asli utuh, salinan by value kehilangan tipe, sizeof membuktikan ukuran tujuan, reference tetap utuh, parameter function ikut terpotong, dan container nilai mengalami nasib yang sama:

cpp
#include <iostream>
#include <string>
#include <vector>

class Dokumen {
private:
    std::string judul;

public:
    explicit Dokumen(const std::string& j) : judul(j) {}
    virtual ~Dokumen() = default;   // class dasar polimorfik: destructor virtual

    virtual std::string tipe() const {
        return "Dokumen";
    }
    std::string getJudul() const {
        return judul;
    }
};

class DokumenGambar : public Dokumen {
private:
    int lebar;
    int tinggi;

public:
    DokumenGambar(const std::string& j, int l, int t)
        : Dokumen(j), lebar(l), tinggi(t) {}

    std::string tipe() const override {
        return "DokumenGambar";
    }
    int getLebar() const {
        return lebar;
    }
    int getTinggi() const {
        return tinggi;
    }
};

void kirimSalin(Dokumen d) {            // parameter by value: ikut terpotong
    std::cout << "  by value : " << d.tipe() << " / " << d.getJudul() << "\n";
}

void kirimRef(const Dokumen& d) {       // by reference: tetap utuh
    std::cout << "  by ref   : " << d.tipe() << " / " << d.getJudul() << "\n";
}

int main() {
    DokumenGambar asli("denah.png", 1920, 1080);

    std::cout << "== object asli (DokumenGambar) ==\n";
    std::cout << "  tipe   : " << asli.tipe() << "\n";
    std::cout << "  judul  : " << asli.getJudul() << "\n";
    std::cout << "  ukuran : " << asli.getLebar() << "x" << asli.getTinggi() << "\n";

    Dokumen salinan = asli;   // slicing terjadi di sini

    std::cout << "== salinan by value ke variabel Dokumen ==\n";
    std::cout << "  tipe   : " << salinan.tipe() << "\n";
    std::cout << "  judul  : " << salinan.getJudul() << "\n";
    std::cout << "  sizeof : Dokumen " << sizeof(Dokumen)
              << " byte, DokumenGambar " << sizeof(DokumenGambar)
              << " byte, salinan " << sizeof(salinan) << " byte\n";

    const Dokumen& viaRef = asli;   // reference: tidak menyalin apa pun
    std::cout << "== akses lewat reference ke object asli ==\n";
    std::cout << "  tipe   : " << viaRef.tipe() << "\n";

    std::cout << "== dikirim ke function ==\n";
    kirimSalin(asli);
    kirimRef(asli);

    std::cout << "== disimpan di vector nilai ==\n";
    std::vector<Dokumen> rak;
    rak.push_back(DokumenGambar("sketsa.png", 640, 480));
    std::cout << "  rak[0] : " << rak[0].tipe() << "\n";
    return 0;
}

Output yang Dihasilkan:

text
== object asli (DokumenGambar) ==
  tipe   : DokumenGambar
  judul  : denah.png
  ukuran : 1920x1080
== salinan by value ke variabel Dokumen ==
  tipe   : Dokumen
  judul  : denah.png
  sizeof : Dokumen 40 byte, DokumenGambar 48 byte, salinan 40 byte
== akses lewat reference ke object asli ==
  tipe   : DokumenGambar
== dikirim ke function ==
  by value : Dokumen / denah.png
  by ref   : DokumenGambar / denah.png
== disimpan di vector nilai ==
  rak[0] : Dokumen

Mari kita bedah:

  1. Baris 11 (virtual ~Dokumen() = default;): Dokumen adalah class dasar polimorfik, jadi virtual destructor-nya wajib — aturan dari Chapter Virtual Destructors langsung kita pakai meskipun chapter ini tidak menghapus apa pun lewat pointer.
  2. Baris 13-15 (tipe()): virtual function versi dasar menjawab "Dokumen". Jawaban ini menjadi alat ukur: bila sebuah variabel menjawab "Dokumen", dynamic dispatch sedang melihat object yang benar-benar bertipe Dokumen.
  3. Baris 23-24 (lebar dan tinggi): data tambahan milik turunan. Baris 27-28 membangunnya lewat constructor class dasar di member initializer list — pola dari Chapter Inheritance.
  4. Baris 57 (Dokumen salinan = asli;): inilah slicing. Output menunjukkan tipe : Dokumen — panggilan virtual pada salinan menjawab versi dasar, artinya salinan adalah object Dokumen sungguhan, dan lebar/tinggi tidak ikut ke mana pun.
  5. Baris 62-64 (sizeof): bukti ukurannya — di platform pengujian, Dokumen 40 byte dan DokumenGambar 48 byte, sementara salinan 40 byte, sama persis dengan Dokumen. Angka pasti boleh berbeda antar platform, tetapi kesimpulannya tidak pernah berubah: variabel tujuan memang tidak memiliki ruang untuk bagian turunan.
  6. Baris 66-68 (viaRef): reference tidak menyalin apa pun — ia hanyalah nama lain untuk asli. Dynamic dispatch tetap menjawab "DokumenGambar": identitas utuh, persis perilaku yang kita andalkan sepanjang Section ini.
  7. Baris 71-72 (dua function): argumen yang sama menghasilkan jawaban berbeda. kirimSalin menerima parameter by value — penyalinan terjadi di pemanggilan, slicing ikut terjadi — sedangkan kirimRef menerima const Dokumen& dan melihat object utuh.
  8. Baris 75-77 (vector<Dokumen>): container nilai menyimpan setiap elemen seukuran Dokumen. push_back(DokumenGambar(...)) sah secara kompilasi, tetapi elemen yang tersimpan menjawab "Dokumen" — slicing versi container.

Perhatikan satu fakta yang menutup bedah ini: kompilasi program di atas dengan -Wall -Wextra tidak menghasilkan satu pun warning — gcc membiarkan baris Dokumen salinan = asli; lolos tanpa suara. Perlindungan otomatis dari compiler tidak ada; pencegahan sepenuhnya tugas desain kita, dan tiga alat berikutnya yang menguruskannya.

Bentuk Lain yang Menyamar #

Jalur slicing semuanya satu prinsip — penyalinan by value ke tipe class dasar — hanya bentuknya yang beragam. Parameter function by value seperti kirimSalin(Dokumen d) memotong saat argumen dikirim; return by value bertipe class dasar memotong saat hasil dikembalikan; assignment d = asli; pada variabel dasar yang sudah ada memotong pada copy assignment; dan container nilai seperti std::vector<Dokumen> memotong saat elemen disalin ke slot penyimpanannya. Sinyal deteksinya konsisten: bila hierarki polymorphic dilewati parameter, return, assignment, atau elemen container bertipe nilai class dasar, bersiaplah melihat versi dasar saja.

Karena semua jalur itu tertulis terang di signature dan deklarasi, disiplin membaca kode menjadi pertahanan pertama. Pertahanan kedua adalah memilih desain yang membuat penyalinan by value mustahil terjadi — dan di situ kita punya tiga alat yang semuanya sudah dikenal.

Mencegah Slicing #

Alat pertama: rancang API hierarki polymorphic agar menerima const Dokumen& atau pointer, bukan nilai. Reference tidak menyalin apa pun sehingga tidak ada yang bisa terpotong; inilah alasan semua contoh koleksi pada Section ini memakai std::vector<Bentuk*> dan bukan std::vector<Bentuk>. Container pun mengikuti: simpan pointer ke object, bukan objectnya — dengan catatan bahwa pertanyaan kepemilikan memori yang menyertainya kita tuntaskan di Section Manajemen Memori.

Alat kedua: jadikan class dasar abstract seperti pada Chapter Abstract Classes. Object class abstract tidak bisa dibuat, sehingga variabel Dokumen salinan = asli; tidak pernah sampai ke tahap penyalinan — compiler menolaknya sejak awal:

text Error kompilasi: class dasar abstract menolak penyalinan by value
error: cannot allocate an object of abstract type 'Dokumen'
note:   because the following virtual functions are pure within 'Dokumen':
    'virtual std::string Dokumen::tipe() const'
error: cannot declare variable 'salinan' to be of abstract type 'Dokumen'

Ini bentuk perlindungan yang paling tegas: kesalahan berhenti di waktu kompilasi, bukan sebagai data yang hilang di runtime. Alat ketiga bersifat disiplin tim: sepakati bahwa parameter, return, dan anggota container untuk hierarki polymorphic selalu lewat reference atau pointer, lalu biarkan code review menjaga kesepakatan itu.

Sampai di sini, seluruh alat OOP sudah kita pegang: encapsulation, composition, inheritance, virtual function, polymorphism, dan penanganan jebakannya. Yang tersisa adalah pertanyaan desain yang paling sering ditanyakan pemula — kapan sebaiknya mewarisi, dan kapan sebaiknya menyusun? Chapter terakhir Section ini membandingkan keduanya secara berhadapan.

Rujukan resmi: Derived classes (cppreference) , Copy constructors (cppreference) .