Rule of Three, Five, dan Zero

Aturan desain kelas yang mengelola sumber daya sendiri.

Daftar isi

Topik ini membahas Rule of Three, Rule of Five, dan Rule of Zero, yaitu tiga pedoman desain untuk kelas yang mengelola sumber daya sendiri, misalnya buffer memori yang didefinisikan dengan new. Materinya mencakup alasan destructor, copy constructor, dan copy assignment harus hadir bersamaan, perluasan aturan menjadi lima operasi dengan move constructor dan move assignment, serta pilihan yang paling aman berupa member yang menyalin dirinya dengan benar. Setelah membacanya, keputusan tentang kelas mana yang memerlukan operasi khusus dan kelas mana yang cukup dengan penyalinan default dapat diambil dengan dasar yang jelas.

Rule of Three#

Rule of Three menyatakan bahwa kelas yang memerlukan satu dari tiga operasi khusus, yaitu destructor, copy constructor, dan copy assignment yang didefinisikan sendiri, hampir pasti memerlukan ketiganya. Alasannya bersifat logis dan bukan sekadar kebiasaan. Destructor yang melepas sumber daya menandakan kelas memiliki member yang menunjuk sumber daya luar, dan member semacam itulah yang paling rusak apabila disalin secara dangkal. Menyediakan satu operasi saja membuat dua jalur lain tetap menggunakan penyalinan default yang berbahaya bagi member tersebut.

Kelas RiwayatSuhu berikut menyimpan pengukuran dalam array dinamis yang didefinisikan dengan new dan dilepas oleh destructor. Ketiga operasi Rule of Three ditulis lengkap, sehingga setiap salinan memiliki array miliknya sendiri.

cpp riwayat-suhu.cpp
#include <iostream>

class RiwayatSuhu {
private:
    double* data;
    int jumlah;

public:
    RiwayatSuhu(int kapasitas) : jumlah(kapasitas) {
        data = new double[jumlah];
        for (int i = 0; i < jumlah; i++) {
            data[i] = 0.0;
        }
    }

    RiwayatSuhu(const RiwayatSuhu& lain) : jumlah(lain.jumlah) {
        data = new double[jumlah];
        for (int i = 0; i < jumlah; i++) {
            data[i] = lain.data[i];
        }
    }

    RiwayatSuhu& operator=(const RiwayatSuhu& lain) {
        if (this != &lain) {
            delete[] data;
            jumlah = lain.jumlah;
            data = new double[jumlah];
            for (int i = 0; i < jumlah; i++) {
                data[i] = lain.data[i];
            }
        }
        return *this;
    }

    ~RiwayatSuhu() {
        delete[] data;
    }

    void catat(int indeks, double nilai) {
        data[indeks] = nilai;
    }

    double baca(int indeks) const {
        return data[indeks];
    }
};

int main() {
    RiwayatSuhu pagi(3);
    pagi.catat(0, 24.5);
    pagi.catat(1, 26.0);

    RiwayatSuhu salin = pagi;
    salin.catat(0, 30.5);

    std::cout << "pagi [0] = " << pagi.baca(0) << "\n";
    std::cout << "salin [0] = " << salin.baca(0) << "\n";
    return 0;
}
text Keluaran
pagi [0] = 24.5
salin [0] = 30.5

Pengubahan salin tidak mengubah pagi karena keduanya memegang buffer terpisah, dan penghancuran keduanya tidak bertabrakan karena delete[] dijalankan atas buffer yang berbeda. Copy constructor menyalin seluruh elemen satu per satu, sedangkan copy assignment melepas buffer lama terlebih dahulu lalu menyalin dengan pola yang sama. Pemeriksaan self-assignment di awal copy assignment menjadi kebiasaan baku pada kelas semacam ini, sebagaimana dijelaskan pada bahasan copy assignment.

Rule of Five#

C++11 menambahkan dua operasi ke dalam kelompok ini, yaitu move constructor dan move assignment, sehingga pedoman yang diperluas disebut Rule of Five. Kedua operasi move memindahkan sumber daya dari objek sumber ke objek tujuan alih-alih menyalinnya, sehingga buffer tidak perlu dialokasikan ulang untuk objek yang akan segera berakhir. Kelas yang mengelola sumber daya besar memperoleh manfaat nyata dari pemindahan ini, misalnya saat isi objek dikembalikan dari sebuah fungsi.

Ketiadaan dua operasi move tersebut tidak menimbulkan error, sehingga kekurangannya mudah lolos tanpa disadari. Melengkapi kelima operasi, atau setidaknya menyadari kekosongan kedua operasi move, adalah inti dari Rule of Five. Kerangka deklarasi kelima operasinya terlihat pada contoh berikut.

cpp Contoh: kelima operasi khusus pada kelas pengelola buffer
// tidak-dijalankan (kerangka deklarasi tanpa badan implementasi, untuk mengenali kelima operasi)
class RiwayatSuhu {
public:
    RiwayatSuhu();                                    // constructor
    ~RiwayatSuhu();                                   // destructor
    RiwayatSuhu(const RiwayatSuhu& lain);             // copy constructor
    RiwayatSuhu& operator=(const RiwayatSuhu& lain);  // copy assignment
    RiwayatSuhu(RiwayatSuhu&& lain);                  // move constructor
    RiwayatSuhu& operator=(RiwayatSuhu&& lain);       // move assignment
};

Rule of Zero#

Rule of Zero adalah pilihan yang dianjurkan untuk kelas yang bukan pengelola sumber daya. Gagasannya, kelas tidak menulis satu pun dari kelima operasi khusus karena seluruh membernya merupakan tipe yang menyalin, memindahkan, dan melepas dirinya dengan benar, seperti std::string, std::vector, dan smart pointer. Penyalinan default yang dibuat compiler menjadi benar tanpa usaha tambahan, karena setiap member yang disalin ikut menyalin isinya secara lengkap.

Kelas DaftarBelanja berikut tidak memiliki destructor maupun operasi penyalinan, tetapi salinannya berperilaku seperti deep copy karena member butir yang bertipe std::vector menyalin seluruh elemennya.

cpp daftar-belanja.cpp
#include <iostream>
#include <string>
#include <vector>

class DaftarBelanja {
private:
    std::string pemilik;
    std::vector<std::string> butir;

public:
    DaftarBelanja(const std::string& p) : pemilik(p) {
    }

    void tambah(const std::string& b) {
        butir.push_back(b);
    }

    void tampilkan() const {
        std::cout << "Daftar " << pemilik << ":\n";
        for (const std::string& b : butir) {
            std::cout << "- " << b << "\n";
        }
    }
};

int main() {
    DaftarBelanja rumah("rumah");
    rumah.tambah("beras");
    rumah.tambah("minyak");

    DaftarBelanja kantor = rumah;
    kantor.tambah("kertas");

    rumah.tampilkan();
    kantor.tampilkan();
    return 0;
}
text Keluaran
Daftar rumah:
- beras
- minyak
Daftar rumah:
- beras
- minyak
- kertas

Perubahan pada kantor tidak memengaruhi rumah, padahal kelas itu tidak berisi satu pun operasi khusus. Ketika member butir bertambah atau berganti tipe, tidak ada operasi penyalinan yang perlu disesuaikan, sehingga kelas ini tidak menumpuk kode yang rawan keliru. Rule of Zero menghilangkan sebagian besar pekerjaan Rule of Three dengan memindahkan tanggung jawab pengelolaan sumber daya kepada tipe yang memang dirancang untuk itu.

Memilih aturan untuk sebuah kelas#

Ketiga aturan bukan sekadar pilihan gaya, melainkan konsekuensi dari bentuk member kelas. Keputusannya dapat dirangkum dengan melihat apakah kelas itu sendiri yang mendefinisikan dan melepas sumber daya. Tabel berikut merangkum pemilihannya.

Bentuk kelasAturan yang sesuai
Seluruh member berupa tipe yang menyalin dirinya dengan benarRule of Zero, tanpa satu pun operasi khusus
Kelas yang mendefinisikan sumber daya dengan new dan melepaskannya dengan deleteRule of Three sebagai minimum
Kelas pengelola sumber daya yang isinya sering dikirim antar-objekRule of Five agar pemindahan menggantikan penyalinan

Pilihan yang keliru berujung pada dua jenis kegagalan. Kelas pengelola sumber daya tanpa operasi penyalinan yang benar mengalami double-free atau kebocoran memori sebagaimana dibahas pada bahasan copy constructor dan copy assignment. Sebaliknya, menulis kelima operasi pada kelas yang membernya sudah aman menambah kode yang tidak diperlukan dan harus dipelihara. Karena itu urutan keputusannya sederhana, yaitu mengusahakan Rule of Zero lebih dahulu, lalu menulis kelima operasi hanya pada kelas yang tugasnya memang mengelola sumber daya.

Kesalahan umum#

Empat kekeliruan berikut sering muncul saat menerapkan aturan-aturan ini.

  • Menambahkan destructor yang memanggil delete[] tanpa menyertakan copy constructor dan copy assignment. Penyalinan default menghasilkan dua objek dengan buffer bersama, lalu double-free saat keduanya dihancurkan.
  • Menulis copy constructor tetapi melupakan copy assignment, atau sebaliknya. Salah satu jalur penyalinan tetap menggunakan versi default yang berbahaya.
  • Mengira compiler tetap membuat operasi move setelah operasi penyalinan ditulis sendiri. Keduanya tidak dibuat, sehingga pemindahan diam-diam berubah menjadi penyalinan.
  • Menulis kelima operasi untuk kelas yang membernya seluruhnya std::string atau std::vector. Kode itu tidak salah, tetapi menambah beban pemeliharaan yang dapat dihindari dengan Rule of Zero.

Ringkasan#

  • Rule of Three menyatakan bahwa kelas yang memerlukan satu operasi khusus dari destructor, copy constructor, dan copy assignment hampir pasti memerlukan ketiganya.
  • Rule of Five menambahkan move constructor dan move assignment, yang tidak dibuat compiler apabila operasi lain didefinisikan sendiri.
  • Rule of Zero menyerahkan pengelolaan sumber daya kepada member seperti std::string dan std::vector, sehingga kelima operasi tidak perlu ditulis.
  • Urutan keputusan yang dianjurkan adalah mengusahakan Rule of Zero lebih dahulu, lalu menulis operasi khusus hanya untuk kelas pengelola sumber daya.

Referensi#