Rc<T>, Con Trỏ Thông Minh Đếm Tham Chiếu

Trong đa số trường hợp, quyền sở hữu (ownership) rất rõ ràng: bạn biết chính xác biến nào sở hữu một giá trị cụ thể. Tuy nhiên, có những trường hợp một giá trị có thể có nhiều chủ sở hữu. Ví dụ, trong cấu trúc dữ liệu đồ thị, nhiều cạnh có thể trỏ đến cùng một nút, và nút đó về mặt khái niệm được sở hữu bởi tất cả các cạnh trỏ đến nó. Một nút không nên được dọn dẹp trừ khi nó không có bất kỳ cạnh nào trỏ đến và do đó không có chủ sở hữu nào.

Bạn phải kích hoạt quyền sở hữu đa chủ một cách rõ ràng bằng cách sử dụng kiểu Rust Rc<T>, viết tắt của reference counting (đếm tham chiếu). Kiểu Rc<T> theo dõi số lượng tham chiếu đến một giá trị để xác định liệu giá trị đó có còn đang được sử dụng hay không. Nếu không có tham chiếu nào đến một giá trị, giá trị có thể được dọn dẹp mà không làm mất hiệu lực bất kỳ tham chiếu nào.

Hãy tưởng tượng Rc<T> như một chiếc TV trong phòng khách gia đình. Khi một người vào xem TV, họ bật nó lên. Những người khác có thể vào phòng và xem TV. Khi người cuối cùng rời khỏi phòng, họ tắt TV vì nó không còn được sử dụng nữa. Nếu ai đó tắt TV trong khi những người khác vẫn đang xem, sẽ có sự phản đối từ những người xem TV còn lại!

Chúng ta sử dụng kiểu Rc<T> khi muốn cấp phát dữ liệu trên heap cho nhiều phần của chương trình đọc và chúng ta không thể xác định tại thời điểm biên dịch phần nào sẽ kết thúc việc sử dụng dữ liệu cuối cùng. Nếu chúng ta biết phần nào sẽ kết thúc cuối cùng, chúng ta có thể đơn giản làm cho phần đó trở thành chủ sở hữu của dữ liệu, và các quy tắc sở hữu thông thường được thực thi tại thời điểm biên dịch sẽ có hiệu lực.

Lưu ý rằng Rc<T> chỉ dùng trong các tình huống đơn luồng. Khi chúng ta thảo luận về đồng thời trong Chương 16, chúng ta sẽ đề cập đến cách thực hiện đếm tham chiếu trong các chương trình đa luồng.

Sử dụng Rc<T> để Chia Sẻ Dữ Liệu

Hãy quay lại ví dụ về danh sách cons trong Listing 15-5. Nhớ rằng chúng ta đã định nghĩa nó bằng Box<T>. Lần này, chúng ta sẽ tạo hai danh sách đều chia sẻ quyền sở hữu của một danh sách thứ ba. Về mặt khái niệm, điều này trông giống như Hình 15-3.

Hai danh sách chia sẻ quyền sở hữu của một danh sách thứ ba

Hình 15-3: Hai danh sách, bc, chia sẻ quyền sở hữu của danh sách thứ ba, a

Chúng ta sẽ tạo danh sách a chứa 5 và sau đó là 10. Sau đó, chúng ta sẽ tạo thêm hai danh sách nữa: b bắt đầu bằng 3c bắt đầu bằng 4. Cả hai danh sách bc sẽ tiếp tục với danh sách a đầu tiên chứa 510. Nói cách khác, cả hai danh sách sẽ chia sẻ danh sách đầu tiên chứa 510.

Việc cố gắng triển khai kịch bản này bằng định nghĩa List với Box<T> sẽ không hoạt động, như được hiển thị trong Listing 15-17:

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));
}

Khi chúng ta biên dịch mã này, chúng ta nhận được lỗi này:

$ 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

Các biến thể Cons sở hữu dữ liệu mà chúng nắm giữ, vì vậy khi chúng ta tạo danh sách b, a được chuyển vào bb sở hữu a. Sau đó, khi chúng ta cố gắng sử dụng a lần nữa khi tạo c, chúng ta không được phép vì a đã bị chuyển đi.

Chúng ta có thể thay đổi định nghĩa của Cons để nắm giữ tham chiếu thay thế, nhưng sau đó chúng ta sẽ phải chỉ định các tham số thời gian sống. Bằng cách chỉ định tham số thời gian sống, chúng ta sẽ chỉ định rằng mọi phần tử trong danh sách sẽ tồn tại ít nhất lâu bằng toàn bộ danh sách. Đây là trường hợp cho các phần tử và danh sách trong Listing 15-17, nhưng không phải trong mọi tình huống.

Thay vào đó, chúng ta sẽ thay đổi định nghĩa của List để sử dụng Rc<T> thay vì Box<T>, như được hiển thị trong Listing 15-18. Mỗi biến thể Cons bây giờ sẽ chứa một giá trị và một Rc<T> trỏ đến một List. Khi chúng ta tạo b, thay vì lấy quyền sở hữu của a, chúng ta sẽ sao chép Rc<List>a đang nắm giữ, do đó tăng số lượng tham chiếu từ một lên hai và cho phép ab chia sẻ quyền sở hữu dữ liệu trong Rc<List> đó. Chúng ta cũng sẽ sao chép a khi tạo c, tăng số lượng tham chiếu từ hai lên ba. Mỗi lần chúng ta gọi Rc::clone, số lượng tham chiếu đến dữ liệu trong Rc<List> sẽ tăng lên, và dữ liệu sẽ không được dọn dẹp trừ khi không có tham chiếu nào đến nó.

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));
}

Chúng ta cần thêm câu lệnh use để đưa Rc<T> vào phạm vi vì nó không có trong prelude. Trong main, chúng ta tạo danh sách chứa 5 và 10 và lưu trữ nó trong một Rc<List> mới trong a. Sau đó, khi chúng ta tạo bc, chúng ta gọi hàm Rc::clone và truyền một tham chiếu đến Rc<List> trong a làm đối số.

Chúng ta có thể đã gọi a.clone() thay vì Rc::clone(&a), nhưng quy ước của Rust là sử dụng Rc::clone trong trường hợp này. Việc triển khai Rc::clone không tạo một bản sao sâu của tất cả dữ liệu như hầu hết các triển khai clone của các kiểu khác. Lệnh gọi đến Rc::clone chỉ tăng số lượng tham chiếu, điều này không tốn nhiều thời gian. Việc sao chép sâu dữ liệu có thể tốn nhiều thời gian. Bằng cách sử dụng Rc::clone cho việc đếm tham chiếu, chúng ta có thể phân biệt trực quan giữa các loại bản sao sâu và các loại bản sao tăng số lượng tham chiếu. Khi tìm kiếm vấn đề về hiệu suất trong mã, chúng ta chỉ cần xem xét các bản sao sâu và có thể bỏ qua các lệnh gọi đến Rc::clone.

Sao Chép một Rc<T> Tăng Số Lượng Tham Chiếu

Hãy thay đổi ví dụ đang hoạt động của chúng ta trong Listing 15-18 để chúng ta có thể thấy số lượng tham chiếu thay đổi khi chúng ta tạo và loại bỏ các tham chiếu đến Rc<List> trong a.

Trong Listing 15-19, chúng ta sẽ thay đổi main để nó có một phạm vi bên trong xung quanh danh sách c; sau đó chúng ta có thể thấy số lượng tham chiếu thay đổi như thế nào khi c ra khỏi phạm vi.

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));
}

Tại mỗi điểm trong chương trình khi số lượng tham chiếu thay đổi, chúng ta in ra số lượng tham chiếu, mà chúng ta nhận được bằng cách gọi hàm Rc::strong_count. Hàm này được đặt tên là strong_count thay vì count vì kiểu Rc<T> cũng có weak_count; chúng ta sẽ xem weak_count được sử dụng để làm gì trong "Ngăn chặn Chu kỳ Tham chiếu Bằng cách Sử dụng Weak<T>".

Mã này in ra như sau:

$ 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

Chúng ta có thể thấy rằng Rc<List> trong a có số lượng tham chiếu ban đầu là 1; sau đó mỗi lần chúng ta gọi clone, số lượng tăng lên 1. Khi c ra khỏi phạm vi, số lượng giảm đi 1. Chúng ta không phải gọi một hàm để giảm số lượng tham chiếu như chúng ta phải gọi Rc::clone để tăng số lượng tham chiếu: việc triển khai của trait Drop tự động giảm số lượng tham chiếu khi một giá trị Rc<T> ra khỏi phạm vi.

Điều chúng ta không thể thấy trong ví dụ này là khi b và sau đó a ra khỏi phạm vi ở cuối main, số lượng lúc đó là 0, và Rc<List> được dọn dẹp hoàn toàn. Sử dụng Rc<T> cho phép một giá trị có nhiều chủ sở hữu, và số lượng đảm bảo rằng giá trị vẫn hợp lệ miễn là bất kỳ chủ sở hữu nào vẫn còn tồn tại.

Thông qua các tham chiếu bất biến, Rc<T> cho phép bạn chia sẻ dữ liệu giữa nhiều phần của chương trình để chỉ đọc. Nếu Rc<T> cho phép bạn có nhiều tham chiếu thay đổi cũng vậy, bạn có thể vi phạm một trong các quy tắc mượn đã thảo luận trong Chương 4: nhiều lần mượn thay đổi đến cùng một nơi có thể gây ra các cuộc đua dữ liệu và sự không nhất quán. Nhưng khả năng thay đổi dữ liệu rất hữu ích! Trong phần tiếp theo, chúng ta sẽ thảo luận về mẫu khả biến nội bộ và kiểu RefCell<T> mà bạn có thể sử dụng kết hợp với Rc<T> để làm việc với hạn chế bất biến này.