Rc<T>، اشاره‌گر (Pointer) هوشمند با شمارش مرجع

در بیشتر موارد، مالکیت واضح است: شما دقیقاً می‌دانید که کدام متغیر مالک یک مقدار مشخص است. با این حال، در مواردی ممکن است یک مقدار چندین مالک داشته باشد. برای مثال، در ساختارهای داده گراف، چندین یال ممکن است به یک گره اشاره کنند و آن گره از نظر مفهومی متعلق به تمام یال‌هایی است که به آن اشاره دارند. یک گره نباید پاکسازی شود مگر اینکه هیچ یالی به آن اشاره نکند و در نتیجه مالکیتی نداشته باشد.

برای فعال‌سازی مالکیت چندگانه باید به صورت صریح از نوع Rc<T> در Rust استفاده کنید که مخفف reference counting یا شمارش مرجع است. نوع Rc<T> تعداد مراجعات به یک مقدار را دنبال می‌کند تا مشخص کند که آیا آن مقدار هنوز در حال استفاده است یا خیر. اگر هیچ مرجعی به یک مقدار وجود نداشته باشد، مقدار می‌تواند بدون اینکه هیچ مرجعی نامعتبر شود، پاکسازی شود.

تصور کنید Rc<T> مانند یک تلویزیون در اتاق نشیمن است. وقتی یک نفر وارد اتاق می‌شود تا تلویزیون تماشا کند، آن را روشن می‌کند. افراد دیگری هم می‌توانند وارد اتاق شوند و تلویزیون تماشا کنند. وقتی آخرین نفر اتاق را ترک می‌کند، تلویزیون را خاموش می‌کند زیرا دیگر استفاده نمی‌شود. اگر کسی تلویزیون را در حالی که دیگران هنوز در حال تماشای آن هستند خاموش کند، اعتراض تماشاگران باقی‌مانده بلند خواهد شد!

ما از نوع Rc<T> استفاده می‌کنیم وقتی می‌خواهیم مقداری را در هیپ تخصیص دهیم که توسط چندین بخش از برنامه ما خوانده شود و نمی‌توانیم در زمان کامپایل تعیین کنیم که کدام بخش استفاده از داده را زودتر به پایان می‌رساند. اگر می‌دانستیم کدام بخش زودتر تمام می‌شود، می‌توانستیم آن بخش را مالک داده کنیم و قوانین معمول مالکیت که در زمان کامپایل اعمال می‌شود، اعمال می‌شد.

توجه داشته باشید که Rc<T> فقط برای استفاده در سناریوهای تک‌ریسمانی است. هنگامی که در فصل 16 در مورد هم‌زمانی بحث می‌کنیم، نحوه انجام شمارش مرجع در برنامه‌های چندریسمانی را پوشش خواهیم داد.

استفاده از Rc<T> برای اشتراک‌گذاری داده

بیایید به مثال لیست cons در لیست 15-5 بازگردیم. به یاد داشته باشید که ما آن را با استفاده از Box<T> تعریف کردیم. این بار، دو لیست ایجاد می‌کنیم که هر دو مالکیت یک لیست سوم را به اشتراک می‌گذارند. به طور مفهومی، این مشابه شکل 15-3 به نظر می‌رسد:

دو لیست که مالکیت یک لیست سوم را به اشتراک می‌گذارند

شکل 15-3: دو لیست، b و c، که مالکیت یک لیست سوم، a را به اشتراک می‌گذارند

ما لیست a را ایجاد می‌کنیم که شامل 5 و سپس 10 است. سپس دو لیست دیگر ایجاد می‌کنیم: b که با 3 شروع می‌شود و c که با 4 شروع می‌شود. هر دو لیست b و c سپس ادامه می‌دهند به لیست اول a که شامل 5 و 10 است. به عبارت دیگر، هر دو لیست مالکیت لیست اول که شامل 5 و 10 است را به اشتراک می‌گذارند.

تلاش برای پیاده‌سازی این سناریو با استفاده از تعریف ما از List با Box<T> کار نخواهد کرد، همان‌طور که در لیست 15-17 نشان داده شده است:

Filename: src/main.rs
enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}
Listing 15-17: نشان دادن اینکه نمی‌توانیم دو لیست با استفاده از Box<T> داشته باشیم که سعی در اشتراک‌گذاری مالکیت یک لیست سوم دارند

هنگامی که این کد را کامپایل می‌کنیم، با این خطا مواجه می‌شویم:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0382]: use of moved value: `a`
  --> src/main.rs:11:30
   |
9  |     let a = Cons(5, Box::new(Cons(10, Box::new(Nil))));
   |         - move occurs because `a` has type `List`, which does not implement the `Copy` trait
10 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
11 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `cons-list` (bin "cons-list") due to 1 previous error

متغیرهای Cons مالک داده‌هایی هستند که در خود نگه می‌دارند. بنابراین، هنگامی که لیست b را ایجاد می‌کنیم، a به b منتقل می‌شود و b مالک a می‌شود. سپس، هنگامی که سعی می‌کنیم دوباره از a برای ایجاد c استفاده کنیم، این کار مجاز نیست زیرا a قبلاً منتقل شده است.

ما می‌توانیم تعریف Cons را به گونه‌ای تغییر دهیم که به جای نگهداری داده‌ها، ارجاع به آنها را نگه دارد. اما در این صورت باید پارامترهای طول عمر (lifetime parameters) را مشخص کنیم. با مشخص کردن پارامترهای طول عمر، مشخص می‌کنیم که هر عنصر در لیست حداقل به اندازه کل لیست زنده خواهد بود. این موضوع در مورد عناصر و لیست‌های موجود در لیست 15-17 صدق می‌کند، اما در همه سناریوها چنین نیست.

در عوض، تعریف List خود را تغییر می‌دهیم تا به جای Box<T> از Rc<T> استفاده کند، همان‌طور که در لیست 15-18 نشان داده شده است. هر متغیر Cons اکنون یک مقدار و یک Rc<T> اشاره‌کننده به یک List را نگه می‌دارد. وقتی b را ایجاد می‌کنیم، به جای تصاحب مالکیت a، Rc<List> که a نگه می‌دارد را کلون می‌کنیم، بنابراین تعداد ارجاعات از یک به دو افزایش می‌یابد و به a و b اجازه می‌دهیم مالکیت داده‌های موجود در آن Rc<List> را به اشتراک بگذارند. همچنین هنگام ایجاد c، a را کلون می‌کنیم و تعداد ارجاعات از دو به سه افزایش می‌یابد. هر بار که Rc::clone را فراخوانی می‌کنیم، تعداد ارجاعات به داده‌های موجود در Rc<List> افزایش می‌یابد و داده‌ها تا زمانی که هیچ ارجاعی به آنها باقی نماند پاک نمی‌شوند.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, Rc::clone(&a));
    let c = Cons(4, Rc::clone(&a));
}
Listing 15-18: تعریفی از List که از Rc<T> استفاده می‌کند

باید یک دستور use اضافه کنیم تا Rc<T> را به دامنه بیاوریم زیرا این نوع به صورت پیش‌فرض در prelude نیست. در main، لیستی که شامل 5 و 10 است ایجاد می‌کنیم و آن را در یک Rc<List> جدید در a ذخیره می‌کنیم. سپس هنگامی که b و c را ایجاد می‌کنیم، تابع Rc::clone را فراخوانی می‌کنیم و یک ارجاع به Rc<List> موجود در a را به عنوان آرگومان می‌فرستیم.

می‌توانستیم a.clone() را به جای Rc::clone(&a) فراخوانی کنیم، اما طبق قرارداد Rust در این موارد از Rc::clone استفاده می‌شود. پیاده‌سازی Rc::clone یک کپی عمیق از تمام داده‌ها ایجاد نمی‌کند، همان‌طور که پیاده‌سازی اکثر انواع دیگر clone این کار را انجام می‌دهد. فراخوانی Rc::clone فقط تعداد ارجاعات را افزایش می‌دهد، که زمان زیادی نمی‌برد. کپی عمیق داده‌ها ممکن است زمان زیادی ببرد. با استفاده از Rc::clone برای شمارش مرجع، می‌توانیم بین کپی‌های عمیق و کپی‌هایی که تعداد ارجاعات را افزایش می‌دهند تمایز بصری قائل شویم. هنگام جستجوی مشکلات عملکرد در کد، فقط لازم است به کپی‌های عمیق توجه کنیم و می‌توانیم فراخوانی‌های Rc::clone را نادیده بگیریم.

کلون کردن یک Rc<T> تعداد ارجاعات را افزایش می‌دهد

اجازه دهید مثال کاری خود را در لیست 15-18 تغییر دهیم تا بتوانیم تغییرات تعداد ارجاعات را هنگام ایجاد و حذف ارجاعات به Rc<List> در a مشاهده کنیم.

در لیست 15-19، main را تغییر خواهیم داد تا یک محدوده داخلی (inner scope) در اطراف لیست c داشته باشد؛ سپس می‌توانیم ببینیم که چگونه تعداد ارجاعات زمانی که c از محدوده خارج می‌شود تغییر می‌کند.

Filename: src/main.rs
enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use crate::List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Cons(3, Rc::clone(&a));
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, Rc::clone(&a));
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}
Listing 15-19: چاپ تعداد ارجاعات

در هر نقطه از برنامه که تعداد ارجاعات تغییر می‌کند، تعداد ارجاعات را چاپ می‌کنیم که از طریق فراخوانی تابع Rc::strong_count دریافت می‌شود. این تابع به جای count، strong_count نام‌گذاری شده است زیرا نوع Rc<T> همچنین دارای weak_count است؛ در بخش “جلوگیری از چرخه‌های مرجع: تبدیل یک Rc<T> به یک Weak<T> با کاربرد weak_count آشنا خواهیم شد.

این کد خروجی زیر را تولید می‌کند:

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.45s
     Running `target/debug/cons-list`
count after creating a = 1
count after creating b = 2
count after creating c = 3
count after c goes out of scope = 2

می‌بینیم که Rc<List> در a تعداد ارجاع اولیه برابر با 1 دارد؛ سپس هر بار که clone را فراخوانی می‌کنیم، تعداد ارجاعات 1 واحد افزایش می‌یابد. هنگامی که c از محدوده خارج می‌شود، تعداد ارجاعات 1 واحد کاهش می‌یابد. لازم نیست تابعی برای کاهش تعداد ارجاعات فراخوانی کنیم، همان‌طور که باید Rc::clone را برای افزایش تعداد ارجاعات فراخوانی کنیم: پیاده‌سازی ویژگی Drop تعداد ارجاعات را به طور خودکار کاهش می‌دهد وقتی که یک مقدار Rc<T> از محدوده خارج می‌شود.

آنچه در این مثال نمی‌توانیم ببینیم این است که وقتی b و سپس a در انتهای main از محدوده خارج می‌شوند، تعداد ارجاعات به 0 می‌رسد و Rc<List> به طور کامل پاک‌سازی می‌شود. استفاده از Rc<T> به یک مقدار اجازه می‌دهد که چندین مالک داشته باشد، و تعداد ارجاعات تضمین می‌کند که مقدار تا زمانی که هر یک از مالکان هنوز وجود دارند، معتبر باقی می‌ماند.

از طریق ارجاعات غیرقابل تغییر، Rc<T> به شما اجازه می‌دهد داده‌ها را بین بخش‌های مختلف برنامه خود برای خواندن به اشتراک بگذارید. اگر Rc<T> به شما اجازه می‌داد که چندین ارجاع قابل تغییر نیز داشته باشید، ممکن بود یکی از قوانین قرض‌گیری که در فصل 4 بحث شد را نقض کنید: چندین قرض قابل تغییر به یک مکان می‌تواند باعث ایجاد تناقضات و مسابقه داده‌ها شود. اما توانایی تغییر داده‌ها بسیار مفید است! در بخش بعدی، به الگوی تغییر‌پذیری داخلی (interior mutability) و نوع RefCell<T> که می‌توانید همراه با Rc<T> برای کار با این محدودیت عدم تغییر‌پذیری استفاده کنید، خواهیم پرداخت.