Rule of Five dan Zero ditinjau ulang

Menggabungkan member khusus dengan move untuk gambaran lengkapnya.

Daftar isi

Lima operasi khusus kelas, yaitu destructor, copy constructor, copy assignment operator, move constructor, dan move assignment operator, menentukan seluruh perilaku masa hidup sebuah tipe. Topik ini meninjau ulang aturan penulisannya setelah move semantics: kapan kelima operasi wajib ditulis sebagai satu paket sesuai Rule of Five, dan kapan semuanya boleh didelegasikan kepada compiler sesuai Rule of Zero.

Dari Rule of Three ke Rule of Five#

Sebelum C++11, aturan yang lazim adalah Rule of Three. Kelas yang mengelola sumber daya secara manual perlu menulis ketiga operasinya sekaligus, yaitu destructor, copy constructor, dan copy assignment operator. Melengkapi satu operasi tanpa dua lainnya menyisakan operasi tersirat yang hanya menyalin nilai pointer mentahnya, yaitu penyalinan dangkal (shallow copy), sehingga dua objek mengklaim sumber daya yang sama dan programnya rusak saat kedua destructor berjalan.

C++11 menambahkan dua operasi, sehingga paketnya menjadi lima dan aturannya dikenal sebagai Rule of Five. Move constructor dan move assignment perlu ditulis bersama tiga operasi lama karena pengelolaan sumber daya kini mencakup pengalihannya juga. Compiler menerapkan satu aturan yang sering terlewat: begitu destructor ditulis secara eksplisit, move constructor dan move assignment tersirat tidak dibuat lagi, sedangkan operasi penyalinan tersirat tetap ada. Akibatnya, kelas seperti itu diam-diam menyalin pointer mentahnya apabila kelima operasinya tidak dilengkapi, dan itulah alasan penulisan kelima operasi dilakukan sebagai satu paket.

Kelas dengan kelima operasi#

Kelas ArsipTeks berikut menulis kelima operasinya secara lengkap untuk satu buffer char dinamis. Penulisan ini menyertakan pengaman terhadap assignment pada dirinya sendiri dan penanda noexcept pada kedua operasi pindah.

cpp rule-of-five.cpp
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <string>
#include <utility>

class ArsipTeks {
public:
    explicit ArsipTeks(std::size_t kapasitas)
        : kapasitas_(kapasitas), teks_(new char[kapasitas] {}) {
        std::cout << "  bangun kapasitas " << kapasitas_ << '\n';
    }

    ArsipTeks(const ArsipTeks& lain)
        : kapasitas_(lain.kapasitas_), teks_(new char[lain.kapasitas_]) {
        std::copy(lain.teks_, lain.teks_ + kapasitas_, teks_);
        std::cout << "  salin\n";
    }

    ArsipTeks& operator=(const ArsipTeks& lain) {
        if (this != &lain) {
            char* baru = new char[lain.kapasitas_];
            std::copy(lain.teks_, lain.teks_ + lain.kapasitas_, baru);
            delete[] teks_;
            teks_ = baru;
            kapasitas_ = lain.kapasitas_;
        }
        std::cout << "  assignment salin\n";
        return *this;
    }

    ArsipTeks(ArsipTeks&& lain) noexcept
        : kapasitas_(lain.kapasitas_), teks_(lain.teks_) {
        lain.kapasitas_ = 0;
        lain.teks_ = nullptr;
        std::cout << "  pindah\n";
    }

    ArsipTeks& operator=(ArsipTeks&& lain) noexcept {
        if (this != &lain) {
            delete[] teks_;
            teks_ = lain.teks_;
            kapasitas_ = lain.kapasitas_;
            lain.teks_ = nullptr;
            lain.kapasitas_ = 0;
        }
        std::cout << "  assignment pindah\n";
        return *this;
    }

    ~ArsipTeks() {
        delete[] teks_;
        std::cout << "  hancur\n";
    }

    void tulis(const std::string& isi) {
        std::size_t n = std::min(isi.size(), kapasitas_ - 1);
        std::copy(isi.begin(), isi.begin() + static_cast<std::ptrdiff_t>(n), teks_);
        teks_[n] = '\0';
    }

    const char* isi() const { return teks_; }

private:
    std::size_t kapasitas_;
    char* teks_;
};

ArsipTeks buatArsip() {
    ArsipTeks arsip(32);
    arsip.tulis("pengukuran suhu 2026");
    return arsip;
}

int main() {
    std::cout << "lima operasi khusus:\n";
    ArsipTeks a(32);
    a.tulis("data lab");

    ArsipTeks b = a;
    ArsipTeks c = std::move(a);
    c = b;

    std::cout << "pengembalian dari fungsi:\n";
    ArsipTeks d = buatArsip();
    std::cout << "  isi d: " << d.isi() << '\n';

    d = std::move(b);
    std::cout << "  isi d: " << d.isi() << '\n';
}
text Keluaran
lima operasi khusus:
  bangun kapasitas 32
  salin
  pindah
  assignment salin
pengembalian dari fungsi:
  bangun kapasitas 32
  isi d: pengukuran suhu 2026
  assignment pindah
  isi d: data lab
  hancur
  hancur
  hancur
  hancur

Setiap baris keluaran dipetakan ke satu operasi. Inisialisasi b dari a memanggil copy constructor yang mengalokasikan buffer sendiri, c dibangun lewat move constructor yang mencuri buffer milik a, dan c = b jatuh ke copy assignment yang menyalin isi ke buffer baru sebelum buffer lamanya dilepaskan. Assignment pindah d = std::move(b) melepaskan isi lama d lebih dahulu, kemudian mengambil buffer milik b, sehingga isi terakhir d berupa data lab.

Bagian pengembalian fungsi tidak mencetak pindah sama sekali, karena compiler melakukan NRVO terhadap variabel arsip sehingga objek yang dibangun di dalam buatArsip() adalah objek d itu sendiri. Empat baris hancur di akhir berasal dari destructor empat objek pada main, dan pemusnahan berjalan aman meskipun a dan b sudah tercuri sumber dayanya, karena kedua operasi pindah mengosongkan milik sumbernya menjadi nullptr.

Rule of Zero#

Ketika setiap member kelas mengelola sumber dayanya sendiri, kelima operasi khusus tidak perlu ditulis sama sekali. Compiler membuat semuanya secara tersirat, dan versi tersirat itu memanggil operasi setiap membernya. std::string menyalin buffer teksnya, std::vector menyalin seluruh elemennya, dan keduanya memindahkan pointer internalnya ketika dipindahkan. Pola ini disebut Rule of Zero, karena tanggung jawab pengelolaan sumber daya berpindah ke tipe member dan compiler yang menyusun kelima operasinya dengan benar.

cpp rule-of-zero.cpp
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class RekapUkur {
public:
    explicit RekapUkur(std::string judul) : judul_(std::move(judul)) {}

    void catat(double nilai) {
        nilai_.push_back(nilai);
    }

    void tampilkan(const char* label) const {
        std::cout << label << ": " << judul_ << ", "
                  << nilai_.size() << " pengukuran\n";
        for (double nilai : nilai_) {
            std::cout << "  " << nilai << '\n';
        }
    }

private:
    std::string judul_;
    std::vector<double> nilai_;
};

int main() {
    RekapUkur asli("rekap lab");
    asli.catat(24.5);
    asli.catat(101.3);

    RekapUkur salinan = asli;
    salinan.catat(68.0);

    RekapUkur pindahan = std::move(asli);

    salinan.tampilkan("salinan");
    pindahan.tampilkan("pindahan");
}
text Keluaran
salinan: rekap lab, 3 pengukuran
  24.5
  101.3
  68
pindahan: rekap lab, 2 pengukuran
  24.5
  101.3

Kelas RekapUkur tidak mendeklarasikan destructor, copy constructor, maupun kedua operasi pindah, tetapi penyalinannya menghasilkan salinan yang benar dan pemindahannya menghasilkan pengalihan pointer internal yang murah. Objek asli yang sudah dipindahkan tidak digunakan lagi, dan destructor tersiratnya tetap dijamin berjalan. Bila perilaku tersirat sudah tepat tetapi ingin dinyatakan secara eksplisit, = default meminta versi tersirat dan = delete menonaktifkan operasi, misalnya untuk melarang penyalinan.

Pilihan antara kedua aturan bergantung pada bentuk kelasnya. Kelas yang memang sengaja menjadi pembungkus satu sumber daya, seperti contoh ArsipTeks, memenuhi Rule of Five, sedangkan kelas yang hanya menyusun member yang sudah aman memenuhi Rule of Zero. Delegasi sumber daya ke std::unique_ptr dan std::vector sering mengubah kelas dari kelompok pertama menjadi kelompok kedua.

Member yang menentukan operasi tersirat#

Compiler menyusun operasi tersirat dari member kelas, dan member yang mengelola sumber daya mengubah hasilnya secara mekanis. Kelas yang membawa std::unique_ptr tidak dapat disalin, karena copy constructor tersiratnya didefinisikan sebagai = delete sebab membernya tidak menawarkan penyalinan, sedangkan move constructor tersiratnya tetap dibuat dan cukup memindahkan satu pointer. Kelasnya menjadi tipe yang hanya dapat dipindahkan tanpa satu baris operasi khusus yang ditulis. Efek ini merupakan sisi lain dari Rule of Zero, yaitu member yang melarang sebagian operasi sekaligus.

Atribut tipe memeriksa keadaan ini saat kompilasi tanpa membangun objek apa pun.

cpp Contoh: kelas dengan member std::unique_ptr
#include <iostream>
#include <memory>
#include <type_traits>

class Tugas {
private:
    std::unique_ptr<int> hasil_;
};

int main() {
    std::cout << "is_copy_constructible: "
              << (std::is_copy_constructible<Tugas>::value ? "true" : "false") << '\n';
    std::cout << "is_move_constructible: "
              << (std::is_move_constructible<Tugas>::value ? "true" : "false") << '\n';
}
text Keluaran
is_copy_constructible: false
is_move_constructible: true

Keluarannya menegaskan kedua sisi sekaligus. Penyalinan dilaporkan tidak tersedia karena std::unique_ptr menolak disalin, sedangkan pemindahan tetap tersedia dan memindahkan kepemilikan hasil_. Apabila kelas semacam ini memang harus dapat disalin, membernya perlu diganti ke tipe yang menyalin dirinya atau operasi penyalinannya ditulis secara eksplisit, dan keputusan itu sebaiknya dibuat lewat desain, bukan ditemukan lewat pesan error compiler.

Kesalahan umum#

  • Menulis destructor tanpa mempertimbangkan operasi pindah. Deklarasi destructor menghentikan pembuatan move constructor dan move assignment tersirat, sehingga penyalinan pointer mentah kembali terjadi tanpa peringatan. Lengkapi kelima operasinya atau delegasikan sumber dayanya ke member yang mengelola dirinya sendiri.
  • Move assignment yang tidak melepaskan isi lama. Operasi pindah yang langsung mengambil pointer sumber tanpa menghapus milik tujuannya membuat sumber daya lama bocor. Lepaskan isi tujuan terlebih dahulu, seperti delete[] pada contoh ArsipTeks.
  • Menulis kelima operasi untuk kelas yang tidak mengelola sumber daya apa pun. Kelas seperti itu memenuhi Rule of Zero, dan operasi yang ditulis manual hanya menambah pemeliharaan serta menutup peluang optimasi member barunya. Tulis operasi khusus hanya bila ada sumber daya yang benar-benar dikelola.

Ringkasan#

  • Rule of Five menuntut kelima operasi khusus ditulis sebagai satu paket, karena menulis satu di antaranya, terutama destructor, mengubah kelahiran operasi pindah tersirat.
  • Operasi pindah yang benar mencuri sumber daya milik sumber, mengosongkan sumbernya, dan ditandai noexcept agar container berani memilihnya.
  • Rule of Zero memindahkan seluruh tanggung jawab itu ke member yang mengelola dirinya sendiri, sehingga compiler menyusun kelima operasinya dengan benar tanpa satu baris pun ditulis.

Referensi#