Pola Rc<RefCell<T>>

Struktur mirip graph beserta biaya dan risikonya.

Daftar isi

Topik ini membahas pola Rc<RefCell<T>>, yaitu kombinasi kepemilikan bersama dan interior mutability untuk membangun struktur yang lebih bebas daripada pohon, seperti graf (graph) yang setiap simpulnya dapat dimiliki banyak simpul lain sekaligus menambah dan menghapus tetangganya. Pembahasan mencakup cara kerja kombinasi kedua tipe tersebut, kebocoran memori yang ditimbulkan siklus referensi, pemutusan siklus dengan Weak<T>, serta biaya yang menyertai pola ini. Setelah membaca topik ini, struktur mirip graf dapat dibangun dengan mengetahui titik-titik rapuhnya, dan kebocoran memori akibat siklus dapat dicegah sejak tahap perancangan.

Menggabungkan kepemilikan bersama dengan mutasi dari dalam#

Rc menghadirkan banyak pemilik tetapi hanya membagikan akses baca, sedangkan RefCell menghadirkan mutasi tetapi hanya melayani satu pemilik. Kebutuhan struktur tertentu berada tepat di perpotongan keduanya: sebuah simpul dimiliki lebih dari satu simpul lain, dan daftar tetangga setiap simpul bertambah atau berkurang selama program berjalan. Pola Rc<RefCell<T>> memenuhi kebutuhan itu dengan memberi lapisan luar kepemilikan bersama dan lapisan dalam gerbang pinjaman runtime, sehingga setiap pemilik dapat mengubah isi melalui gerbang yang sama.

Bentuk yang paling sering dijumpai adalah Rc<RefCell<Vec<Rc<Simpul>>>> sebagai medan daftar tetangga. Tipe itu terbaca dari dalam ke luar sebagai berikut: Vec menampung daftar tetangga yang tiap unsurnya berupa Rc<Simpul>, RefCell membungkus Vec agar daftarnya dapat bertambah meskipun simpulnya dipegang lewat pinjaman baca, dan Rc terluar membuat setiap simpul layak dimiliki bersama oleh banyak simpul lain. Pemilihan tumpukan ini bukan gaya penulisan, melainkan satu-satunya susunan yang memenuhi seluruh kebutuhan struktur berbagi sekaligus berubah.

Graf sederhana dengan daftar tetangga#

Program berikut membangun tiga kota yang masing-masing memiliki daftar tetangga bertipe RefCell<Vec<Rc<Kota>>>. Bandung dan Surabaya sama-sama menunjuk Jakarta sebagai tetangga, sehingga Jakarta berpemilik tiga: variabel jakarta, daftar tetangga Bandung, dan daftar tetangga Surabaya. Daftar tetangga Surabaya juga diisi dua kali lewat borrow_mut untuk membuktikan bahwa pengisian berjalan pada struktur yang sedang dipakai bersama.

rust graf-tetangga.rs
use std::cell::RefCell;
use std::rc::Rc;

struct Kota {
    nama: &'static str,
    tetangga: RefCell<Vec<Rc<Kota>>>,
}

fn main() {
    let jakarta = Rc::new(Kota { nama: "Jakarta", tetangga: RefCell::new(Vec::new()) });
    let bandung = Rc::new(Kota { nama: "Bandung", tetangga: RefCell::new(Vec::new()) });
    let surabaya = Rc::new(Kota { nama: "Surabaya", tetangga: RefCell::new(Vec::new()) });

    // daftar tetangga diperbarui lewat pinjaman tulis pada RefCell
    bandung.tetangga.borrow_mut().push(Rc::clone(&jakarta));
    surabaya.tetangga.borrow_mut().push(Rc::clone(&jakarta));
    surabaya.tetangga.borrow_mut().push(Rc::clone(&bandung));

    println!("tetangga Surabaya:");
    for kota in surabaya.tetangga.borrow().iter() {
        println!("  - {}", kota.nama);
    }

    println!("pemilik Jakarta : {}", Rc::strong_count(&jakarta));
    println!("pemilik Bandung : {}", Rc::strong_count(&bandung));
    println!("pemilik Surabaya: {}", Rc::strong_count(&surabaya));
}
text Keluaran
tetangga Surabaya:
  - Jakarta
  - Bandung
pemilik Jakarta : 3
pemilik Bandung : 2
pemilik Surabaya: 1

Keluaran tersebut menegaskan dua sifat pola sekaligus. Sifat kepemilikan bersama tampak pada hitungan Jakarta yang bernilai tiga meskipun nilai kota itu hanya tersimpan satu kali di heap, dan sifat mutasi tampak pada daftar tetangga yang berhasil diisi melalui borrow_mut atas medan internal. Graf pada contoh ini masih searah: Bandung dan Surabaya menunjuk Jakarta, sedangkan Jakarta tidak menunjuk siapa pun, sehingga seluruh hitungan dapat mencapai nol ketika ketiga variabel keluar dari lingkup. Arah sisi inilah yang menentukan aman atau tidaknya pola ini, dan pembahasannya ada pada bagian berikutnya.

Siklus referensi membocorkan memori#

Pemilik bersama yang saling menahan membentuk siklus referensi, dan siklus seperti itu tidak pernah dibebaskan. Ketika dua Rc saling memegang sebagai pemilik, hitungan kuat masing-masing tidak akan pernah turun ke nol karena penurunan terakhir menunggu pihak lain yang juga menunggu. Memori yang dialokasikan tetap sah dan tidak merusak, tetapi tidak pernah dikembalikan, sehingga keadaan ini disebut kebocoran memori. Program berikut membuktikannya dengan mencetak pesan pada drop, pertama untuk pasangan tanpa siklus, lalu untuk pasangan yang saling menahan.

rust siklus-bocor.rs
use std::cell::RefCell;
use std::rc::Rc;

struct Kota {
    nama: &'static str,
    tetangga: RefCell<Vec<Rc<Kota>>>,
}

impl Drop for Kota {
    fn drop(&mut self) {
        println!("Kota {} dijatuhkan", self.nama);
    }
}

fn main() {
    println!("-- tanpa siklus --");
    {
        let bandung = Rc::new(Kota { nama: "Bandung", tetangga: RefCell::new(Vec::new()) });
        let bogor = Rc::new(Kota { nama: "Bogor", tetangga: RefCell::new(Vec::new()) });

        // hanya Bogor yang menunjuk Bandung, tidak berbalik arah
        bogor.tetangga.borrow_mut().push(Rc::clone(&bandung));
    } // kedua kota dijatuhkan di sini

    println!("-- dengan siklus --");
    {
        let cirebon = Rc::new(Kota { nama: "Cirebon", tetangga: RefCell::new(Vec::new()) });
        let tegal = Rc::new(Kota { nama: "Tegal", tetangga: RefCell::new(Vec::new()) });

        // Cirebon dan Tegal saling menahan sebagai pemilik
        cirebon.tetangga.borrow_mut().push(Rc::clone(&tegal));
        tegal.tetangga.borrow_mut().push(Rc::clone(&cirebon));
    } // tidak ada pesan drop dari kedua kota

    println!("main selesai");
}
text Keluaran
-- tanpa siklus --
Kota Bogor dijatuhkan
Kota Bandung dijatuhkan
-- dengan siklus --
main selesai

Pada blok pertama, pembebasan berjalan berantai: Bogor dijatuhkan lebih dahulu karena dideklarasikan terakhir, dan penjatuhannya melepaskan Rc milik Bandung sehingga Bandung menyusul dijatuhkan. Pada blok kedua, keluar dari lingkup hanya menurunkan hitungan Cirebon dan Tegal dari dua menjadi satu, karena masing-masing masih ditahan daftar tetangga pasangannya, dan pesan drop tidak pernah tercetak. Diagram berikut memperlihatkan keadaan yang menahan kedua simpul tersebut.

cirebon: Rc<Kota>hitungan kuat 2tegal: Rc<Kota>hitungan kuat 2Rc kuatRc kuatsetiap simpul menahan simpul lainnya, hitungan tidak pernah mencapai nol
Siklus referensi: dua Rc yang saling menahan membuat hitungan kuat tidak pernah mencapai nol

Memutus siklus dengan Weak#

std::rc::Weak<T> adalah penunjuk yang mengarah ke alokasi Rc tanpa menambah hitungan kuat, sehingga kehadirannya tidak menahan alokasi tetap hidup. Nilai Weak dibuat lewat Rc::downgrade, dan penunjuk itu dapat dinaikkan kembali menjadi Rc lewat metode upgrade yang mengembalikan Option<Rc<T>>: Some apabila alokasi masih hidup, dan None apabila sudah dibebaskan. Sifat ini cocok untuk tautan balik, karena tautan balik tidak boleh menahan pihak yang dituju agar tetap hidup.

Program berikut membangun struktur laporan bertingkat dengan aturan arah yang tegas: kepemilikan mengalir ke bawah melalui Rc pada medan sub, sedangkan tautan balik anak ke induk disimpan sebagai Weak pada medan induk. Implementasi Drop mencetak pesan agar urutan pembebasan terlihat, dan penelusuran naik dari anak pertama ke induknya berlangsung lewat upgrade.

rust pohon-lemah.rs
use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Bagian {
    judul: &'static str,
    sub: RefCell<Vec<Rc<Bagian>>>,
    induk: RefCell<Weak<Bagian>>,
}

impl Bagian {
    fn baru(judul: &'static str) -> Rc<Bagian> {
        Rc::new(Bagian {
            judul,
            sub: RefCell::new(Vec::new()),
            induk: RefCell::new(Weak::new()),
        })
    }

    fn tambah(induk: &Rc<Bagian>, sub: Rc<Bagian>) {
        // tautan balik disimpan sebagai Weak, bukan Rc
        *sub.induk.borrow_mut() = Rc::downgrade(induk);
        induk.sub.borrow_mut().push(sub);
    }
}

impl Drop for Bagian {
    fn drop(&mut self) {
        println!("bagian {} dijatuhkan", self.judul);
    }
}

fn main() {
    let laporan = Bagian::baru("laporan");
    let pendahuluan = Bagian::baru("pendahuluan");
    let penutup = Bagian::baru("penutup");

    Bagian::tambah(&laporan, pendahuluan);
    Bagian::tambah(&laporan, penutup);

    // naik dari anak pertama ke induknya lewat Weak::upgrade
    let anak_pertama = Rc::clone(&laporan.sub.borrow()[0]);
    if let Some(induk) = anak_pertama.induk.borrow().upgrade() {
        println!("induk dari {} adalah {}", anak_pertama.judul, induk.judul);
    }

    println!("strong laporan      : {}", Rc::strong_count(&laporan));
    println!("strong pendahuluan  : {}", Rc::strong_count(&anak_pertama));
}
text Keluaran
induk dari pendahuluan adalah laporan
strong laporan      : 1
strong pendahuluan  : 2
bagian laporan dijatuhkan
bagian pendahuluan dijatuhkan
bagian penutup dijatuhkan

Keluaran tersebut memperlihatkan tiga bukti sekaligus. Hitungan kuat laporan bernilai satu karena anak-anaknya hanya memegang Weak, penelusuran naik ke induk berhasil melalui upgrade yang mengembalikan Some, dan ketika main selesai, seluruh struktur dibebaskan berurutan dari laporan ke anak-anaknya tanpa ada yang tertinggal. Aturan praktisnya dapat dirumuskan singkat: pilih satu arah sebagai arah kepemilikan, biasanya dari induk ke anak, lalu jadikan semua tautan balik sebagai Weak.

Biaya yang menyertai pola ini#

Keringanan struktur dibayar dengan beberapa biaya yang perlu disadari. Pemeriksaan pinjaman bergeser ke runtime, sehingga kesalahan urutan pinjaman yang tadinya ditolak kompilator kini menjadi panic yang dapat muncul jauh dari penyebabnya. Setiap akses data menempuh dua lapis pembungkus, yaitu penghitungan Rc dan gerbang RefCell, dan alur kepemilikan tidak lagi terbaca dari tanda tangan fungsi karena tersebar di dalam struktur. Pola ini juga membuka peluang kebocoran memori akibat siklus yang tidak disengaja.

Karena itu, pola Rc<RefCell<T>> sebaiknya menjadi pilihan terakhir setelah alternatif yang lebih sederhana terbukti tidak memadai. Struktur yang hanya butuh berbagi tanpa mengubah data cukup memakai Rc tanpa RefCell. Struktur yang berubah sering tetap dapat disusun ulang menjadi pemilik tunggal yang meminjamkan, atau diwakili dengan indeks ke dalam Vec sehingga kepemilikan tetap sederhana. Kombinasi kedua tipe menjadi layak dipakai ketika bentuk data memang bebas seperti graf, dan kesadaran atas risiko siklusnya ditunjukkan dengan penggunaan Weak pada tautan balik.

Kesalahan umum#

Kesalahan pertama adalah membangun seluruh sisi graf dengan Rc kuat karena keseragaman terasa rapi. Keseragaman itu justru menutup kemungkinan pembebasan begitu satu sisi berbalik arah, sebagaimana diperlihatkan pada pasangan Cirebon dan Tegal. Perbaikannya adalah menetapkan arah kepemilikan sejak perancangan, misalnya induk ke anak atau urutan penambahan yang tetap, lalu menyimpan sisi berlawanan sebagai Weak dengan upgrade pada saat dipakai.

Kesalahan kedua adalah memegang RefMut dari satu simpul sambil menelusuri atau mengubah simpul lain yang pada akhirnya kembali ke simpul pertama. Pinjaman tulis yang belum ditutup bertemu dengan pinjaman atas RefCell yang sama melalui jalur yang panjang, dan panic yang muncul menunjuk baris di ujung jalur tersebut alih-alih titik awal pinjamannya. Perbaikannya adalah mengumpulkan dahulu data yang dibutuhkan dari pinjaman baca, menutup pinjaman itu, baru melakukan penulisan pada masing-masing simpul secara terpisah.

Ringkasan#

  • Rc<RefCell<T>> menggabungkan kepemilikan bersama dengan mutasi lewat gerbang runtime, dan bentuk lazimnya adalah medan RefCell<Vec<Rc<Simpul>>> untuk daftar tetangga.
  • Siklus referensi antara pemilik Rc membuat hitungan kuat tidak pernah mencapai nol sehingga memori bocor tanpa pesan error.
  • Weak<T> yang dibuat lewat Rc::downgrade tidak menambah hitungan kuat, dan upgrade mengembalikan Option sesuai keadaan alokasi.
  • Arah kepemilikan ditetapkan satu arah sebagai pohon dengan Rc, sedangkan seluruh tautan balik memakai Weak.

Referensi#