Copy constructor dan copy assignment

Shallow copy versus deep copy dan bug yang muncul dari penyalinan default.

Daftar isi

Topik ini membahas copy constructor dan copy assignment, yaitu dua operasi khusus yang menyalin objek bertipe sama, satu untuk membuat objek baru dan satu untuk mengisi objek yang sudah ada. Materinya mencakup kapan keduanya dipanggil, perilaku penyalinan default yang menyalin member satu per satu, perbedaan shallow copy dan deep copy, serta bug double-free yang lahir dari penyalinan dangkal pada kelas dengan member pointer. Setelah membacanya, kedua operasi penyalinan dapat ditulis dengan benar untuk kelas yang mengelola memori sendiri.

Kapan copy constructor dan copy assignment dipanggil#

Copy constructor membuat objek baru sebagai salinan objek lain, sedangkan copy assignment mengisi objek yang sudah jadi dengan isi objek lain. Keduanya dibuat otomatis oleh compiler, sehingga penyalinan terjadi tanpa satu pun baris kode dari perancang kelas. Perbedaan keduanya mudah tertukar karena bentuk penulisannya mirip. Program berikut mencetak pesan pada setiap operasi penyalinan sehingga waktu pemanggilannya terlihat jelas.

cpp panggilan-penyalinan.cpp
#include <iostream>

class Kotak {
private:
    int isi;

public:
    Kotak(int isiAwal) : isi(isiAwal) {
        std::cout << "Konstruktor Kotak(" << isiAwal << ")\n";
    }

    Kotak(const Kotak& lain) : isi(lain.isi) {
        std::cout << "Copy constructor: salin isi " << lain.isi << "\n";
    }

    Kotak& operator=(const Kotak& lain) {
        std::cout << "Copy assignment: ganti isi " << isi
                  << " menjadi " << lain.isi << "\n";
        isi = lain.isi;
        return *this;
    }
};

void terimaNilai(Kotak k) {
    std::cout << "  terimaNilai selesai\n";
}

int main() {
    Kotak a(10);
    Kotak b = a;
    Kotak c(5);
    c = a;

    terimaNilai(b);
    return 0;
}
text Keluaran
Konstruktor Kotak(10)
Copy constructor: salin isi 10
Konstruktor Kotak(5)
Copy assignment: ganti isi 5 menjadi 10
Copy constructor: salin isi 10
  terimaNilai selesai

Baris Kotak b = a; memanggil copy constructor karena objek b baru dibuat pada baris itu. Baris c = a; memanggil copy assignment karena c sudah dibuat sebelumnya, sehingga yang berlangsung adalah penggantian isi, bukan pembangunan objek. Pengoperan b ke fungsi terimaNilai yang parameternya diterima by value juga memanggil copy constructor, karena parameter merupakan objek baru yang dibangun dari argumennya. Pemisahan ini penting diingat karena keduanya harus ditulis terpisah pada kelas yang mengelola sumber daya.

Penyalinan default menyalin member satu per satu#

Copy constructor dan copy assignment implisit menyalin setiap member menggunakan mekanisme penyalinan milik member itu sendiri. Untuk member bertipe int, double, std::string, atau std::vector, hasilnya benar karena tipe-tipe tersebut menyalin isinya dengan lengkap. Masalah muncul pada member berupa pointer mentah yang menyimpan alamat memori hasil new. Yang disalin hanyalah alamatnya, bukan isi yang ditunjuk, sehingga dua objek berakhir menunjuk buffer yang sama. Keadaan semacam ini disebut shallow copy atau penyalinan dangkal, sedangkan penyalinan yang mengalokasikan buffer baru beserta isinya disebut deep copy atau penyalinan dalam.

AspekShallow copyDeep copy
Yang disalin dari member pointernilai alamatnya sajaisi buffer yang ditunjuk
Buffer memoridibagi dua objeksatu buffer per objek
Perubahan lewat salah satu objekterbaca pada objek lainnyatidak memengaruhi objek lain
Kode yang diperlukantidak ada, dibuat compilerditulis sendiri oleh perancang kelas

Penyalinan dangkal tidak selalu salah. Kelas yang membernya hanya berupa nilai langsung, seperti Kotak pada program pertama, memang cukup dengan penyalinan default. Perbedaan keduanya baru menuntut keputusan desain ketika ada member yang menunjuk sumber daya yang didefinisikan dengan new, karena kepemilikan buffer menjadi tidak jelas.

Bug double-free dari penyalinan dangkal#

Bahaya utama penyalinan dangkal terjadi saat objek dihancurkan. Destructor kelas yang mengelola buffer biasanya memanggil delete[] atas member pointernya. Apabila dua objek menunjuk buffer yang sama karena penyalinan default, destructor kedua objek melepas buffer yang sama dua kali. Program berikut memuat kelas dengan masalah tersebut. Kode ini lolos kompilasi tanpa peringatan, tetapi berhenti dengan error saat dijalankan, sehingga ditandai tidak-dijalankan.

cpp Contoh: kelas dengan penyalinan default yang berujung double-free
// tidak-dijalankan (program ini lolos kompilasi, tetapi berhenti dengan error double-free saat dijalankan)
#include <cstring>

class ArsipRusak {
private:
    char* isi;

public:
    ArsipRusak(const char* teks) {
        isi = new char[std::strlen(teks) + 1];
        std::strcpy(isi, teks);
    }

    ~ArsipRusak() {
        delete[] isi;
    }
    // tanpa copy constructor dan tanpa copy assignment
};

int main() {
    ArsipRusak a("data");
    ArsipRusak b = a;   // penyalinan dangkal: kedua member menunjuk buffer yang sama
    return 0;           // dua destructor melepas buffer yang sama
}

Saat program itu dijalankan, objek a dan b dibangun dengan member isi yang menunjuk buffer identik. Ketika main berakhir, kedua destructor dijalankan dan delete[] isi dipanggil dua kali atas buffer yang sama. Alokator memori mendeteksi kejadian itu dan menghentikan program dengan pesan free(): double free detected in tcache 2. Kegagalan semacam ini tidak terlihat saat kompilasi karena secara tata bahasa seluruh barisnya sah. Perbaikannya bukan menghapus destructor, melainkan melengkapi kelas dengan operasi penyalinan yang menyalin isi buffer, sebagaimana ditunjukkan bagian berikutnya.

Copy constructor yang benar mengalokasikan buffer baru dengan panjang yang sama, lalu menyalin isinya. Copy assignment yang benar melakukan pekerjaan serupa atas objek yang sudah ada, sehingga buffer lama milik objek tujuan harus dilepas lebih dahulu agar tidak bocor. Keduanya juga perlu mengantisipasi self-assignment, yaitu penugasan objek kepada dirinya sendiri seperti x = x, karena melepas buffer sendiri sebelum menyalin dari sumber yang sama berarti membaca memori yang sudah dilepas. Program berikut memuat kelas ArsipTeks dengan ketiga langkah tersebut.

cpp arsip-teks.cpp
#include <cstring>
#include <iostream>

class ArsipTeks {
private:
    char* isi;
    int panjang;

public:
    ArsipTeks(const char* teks) : panjang(std::strlen(teks)) {
        isi = new char[panjang + 1];
        std::strcpy(isi, teks);
    }

    ArsipTeks(const ArsipTeks& lain) : panjang(lain.panjang) {
        isi = new char[panjang + 1];
        std::strcpy(isi, lain.isi);
    }

    ArsipTeks& operator=(const ArsipTeks& lain) {
        if (this != &lain) {
            delete[] isi;
            panjang = lain.panjang;
            isi = new char[panjang + 1];
            std::strcpy(isi, lain.isi);
        }
        return *this;
    }

    ~ArsipTeks() {
        delete[] isi;
    }

    void tampilkan() const {
        std::cout << isi << "\n";
    }
};

int main() {
    ArsipTeks asli("catatan harian");
    ArsipTeks salinan = asli;
    ArsipTeks lain("daftar belanja");
    lain = asli;

    salinan.tampilkan();
    lain.tampilkan();
    return 0;
}
text Keluaran
catatan harian
catatan harian

Pemeriksaan if (this != &lain) membandingkan alamat kedua objek dan melewati seluruh pekerjaan apabila keduanya sama, sehingga penugasan objek kepada dirinya sendiri tidak merusak apa pun. Nilai kembalian *this mengembalikan objek tujuan dan memungkinkan penugasan berantai seperti c = b = a. Dengan kedua operasi tersebut, setiap objek ArsipTeks memiliki buffer sendiri, sehingga penghancurannya tidak pernah bertabrakan dan perubahan isi salah satu objek tidak terbaca pada objek lain.

Perintah berikut mengompilasi berkas itu dan menjalankan hasilnya. Berkas biner ditempatkan di /tmp agar direktori proyek tetap bersih.

bash
# kompilasi arsip-teks.cpp menjadi program di folder sementara /tmp
g++ arsip-teks.cpp -o /tmp/arsip-teks
# jalankan program hasil kompilasi
/tmp/arsip-teks
text Keluaran
catatan harian
catatan harian

Kesalahan umum#

Tiga kekeliruan berikut paling sering muncul saat menangani penyalinan objek.

  • Mengira Kotak b = a; memanggil copy assignment. Bentuk itu adalah inisialisasi, sehingga copy constructor yang berjalan. Copy assignment hanya berlaku ketika objek di ruas kiri sudah dibuat sebelumnya.
  • Membiarkan penyalinan default pada kelas yang member pointernya didefinisikan dengan new dan dilepas oleh destructor. Akibatnya double-free saat kedua objek dihancurkan. Perbaikannya adalah menulis kedua operasi dengan deep copy atau mengganti pointer mentah dengan std::string dan std::vector.
  • Menulis copy assignment tanpa melepas buffer lama dan tanpa menguji self-assignment. Akibatnya kebocoran memori dan pembacaan memori yang sudah dilepas. Perbaikannya adalah menambahkan delete[] sebelum alokasi baru dan pemeriksaan this != &lain di awal fungsi.

Ringkasan#

  • Copy constructor membuat objek baru sebagai salinan, sedangkan copy assignment mengisi objek yang sudah ada, dan keduanya dipanggil secara diam-diam saat inisialisasi, pengoperan parameter by value, dan penugasan.
  • Penyalinan default menyalin member satu per satu, sehingga member pointer mentah menghasilkan shallow copy yang berbagi buffer yang sama.
  • Kelas yang melepas sumber daya pada destructornya wajib menyediakan copy constructor dan copy assignment ber-deep copy agar penghancurannya aman.
  • Copy assignment yang benar melepas buffer lama, mengalokasikan buffer baru, dan menghindari self-assignment dengan pemeriksaan alamat.

Referensi#