Bỏ qua để đến nội dung

Con trỏ thông minh trong Rust

Các quy tắc quyền sở hữu (ownership) và mượn (borrowing) xử lý được hầu hết tình huống, nhưng có vài trường hợp thực tế không vừa khít với ý “đúng một chủ sở hữu”: một giá trị mà phải tới lúc chạy mới biết kích thước, dữ liệu thật sự cần nhiều hơn một chủ sở hữu, hoặc một giá trị bạn cần thay đổi qua một tham chiếu dùng chung. Con trỏ thông minh (smart pointer) là câu trả lời của Rust cho từng trường hợp đó.

Mặc định, giá trị nằm trên stack, và trình biên dịch cần biết trước kích thước của chúng. Box<T> chuyển một giá trị lên heap và đưa cho bạn một con trỏ có kích thước cố định trỏ tới nó. Điều này hữu ích khi kích thước của một kiểu không thể biết lúc biên dịch, thường gặp nhất là với kiểu đệ quy:

fn main() {
let boxed = Box::new(5);
println!("{}", *boxed); // dùng * để lấy giá trị ra
}

Kết quả:

5
▶ Tự chạy thử

Một ví dụ kinh điển về chỗ bạn bắt buộc cần Box: một kiểu chứa chính nó, như một nút của danh sách liên kết:

enum List {
Cons(i32, Box<List>),
Nil,
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
match list {
List::Cons(value, _) => println!("Giá trị đầu tiên: {value}"),
List::Nil => println!("Rỗng"),
}
}

Kết quả:

Giá trị đầu tiên: 1
▶ Tự chạy thử

Nếu không có Box, Cons(i32, List) sẽ cần một không gian vô hạn để lưu: một List chứa một List chứa một List, mãi mãi. Còn Box<List> chỉ là một con trỏ có kích thước cố định, nên trình biên dịch tính được nó cần bao nhiêu chỗ.

Quyền sở hữu nói rằng một giá trị có đúng một chủ sở hữu. Phần lớn thời gian điều đó hoàn toàn hợp lý, nhưng đôi khi bạn thật sự cần nhiều phần của chương trình cùng chia sẻ một dữ liệu. Rc<T> (“reference counted”, đếm tham chiếu) cho phép điều đó bằng cách theo dõi hiện có bao nhiêu chủ sở hữu:

use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("dữ liệu chung"));
let b = Rc::clone(&a); // không phải bản sao sâu, chỉ thêm một chủ sở hữu
let c = Rc::clone(&a);
println!("{a} {b} {c}");
println!("Số chủ sở hữu: {}", Rc::strong_count(&a));
}

Kết quả:

dữ liệu chung dữ liệu chung dữ liệu chung
Số chủ sở hữu: 3
▶ Tự chạy thử

Rc::clone không sao chép dữ liệu bên dưới. Nó chỉ tăng một bộ đếm và trả về thêm một tham chiếu tới cùng giá trị đó. Dữ liệu chỉ được dọn dẹp khi mọi chủ sở hữu đều đã ra khỏi phạm vi (scope).

RefCell<T>: thay đổi giá trị qua một tham chiếu dùng chung

Phần tiêu đề “RefCell<T>: thay đổi giá trị qua một tham chiếu dùng chung”

Bình thường, các quy tắc mượn được kiểm tra lúc biên dịch: hoặc nhiều tham chiếu chỉ đọc, hoặc một tham chiếu khả biến, không bao giờ cả hai. RefCell<T> dời chính việc kiểm tra đó sang lúc chạy, nhờ vậy bạn có thể thay đổi một giá trị ngay cả qua thứ trông như một tham chiếu dùng chung, không có mut:

use std::cell::RefCell;
fn main() {
let value = RefCell::new(5);
*value.borrow_mut() += 1;
println!("{}", value.borrow());
}

Kết quả:

6
▶ Tự chạy thử

RefCell thường đi cặp với Rc: Rc<RefCell<T>> cho bạn một giá trị có nhiều chủ sở hữu mà vẫn thay đổi được, điều mà riêng Rc<T> không cho phép.

Đừng bắt đầu bằng con trỏ thông minh. Hãy bắt đầu với ownership và borrowing thông thường, và chỉ dùng tới chúng khi trình biên dịch đang nói rõ với bạn rằng cách đó không vừa: một kiểu đệ quy (Box), một giá trị thật sự cần nhiều chủ sở hữu (Rc), hoặc thay đổi giá trị qua một tham chiếu dùng chung mà quy tắc mượn không cho phép (RefCell).

1. Cộng các phần tử của một danh sách liên kết

Phần tiêu đề “1. Cộng các phần tử của một danh sách liên kết”

Dùng enum List ở phần Box. Tạo danh sách 1, 2, 3 và viết hàm fn sum(list: &List) -> i32 để cộng mọi giá trị lại. Gợi ý: dùng match trên danh sách. Với Cons(value, rest), kết quả là value cộng với tổng của rest. Với Nil, kết quả là 0.

Xem lời giải
enum List {
Cons(i32, Box<List>),
Nil,
}
fn sum(list: &List) -> i32 {
match list {
List::Cons(value, rest) => value + sum(rest),
List::Nil => 0,
}
}
fn main() {
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Cons(3, Box::new(List::Nil))))));
println!("Tổng: {}", sum(&list));
}

Kết quả:

Tổng: 6
▶ Tự chạy thử

Hàm gọi lại chính nó trên phần còn lại của danh sách cho tới khi gặp Nil. Để ý rằng bạn có thể truyền rest (một &Box<List>) vào chỗ cần &List. Rust tự nhìn xuyên qua Box giúp bạn.

Tạo một danh sách đi chợ (một Vec<String>) mà hai người, alice và bob, cùng sở hữu và cùng thêm món vào được. Mỗi người thêm một món. Sau đó in danh sách và số chủ sở hữu. Gợi ý: bọc Vec trong Rc<RefCell<...>>, đưa cho mỗi người một Rc::clone, và gọi .borrow_mut().push(...) để thêm.

Xem lời giải
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let list = Rc::new(RefCell::new(Vec::new()));
let alice = Rc::clone(&list);
let bob = Rc::clone(&list);
alice.borrow_mut().push(String::from("sữa"));
bob.borrow_mut().push(String::from("bánh mì"));
println!("{:?}", list.borrow());
println!("Số chủ sở hữu: {}", Rc::strong_count(&list));
}

Kết quả:

["sữa", "bánh mì"]
Số chủ sở hữu: 3
▶ Tự chạy thử

Có ba chủ sở hữu: list, alice và bob. Cả ba cùng trỏ tới một Vec, nên món được thêm qua alice cũng thấy được qua bob và list. Không biến nào cần mut, vì RefCell lo phần thay đổi.

  • Box<T> lưu một giá trị trên heap với một con trỏ kích thước cố định, cần thiết cho kiểu đệ quy.
  • Rc<T> cho phép nhiều chủ sở hữu cùng một giá trị trong code đơn luồng, nhờ đếm tham chiếu; giữa các luồng thì dùng Arc<T>.
  • RefCell<T> kiểm tra quy tắc mượn lúc chạy thay vì lúc biên dịch, cho phép thay đổi giá trị qua một tham chiếu dùng chung, nhưng sẽ panic nếu quy tắc bị phá vỡ.
  • Chỉ dùng tới chúng khi ownership và borrowing thông thường không đáp ứng được tình huống của bạn.

Kiểm tra nhanh

1. Vì sao một kiểu đệ quy như nút danh sách liên kết cần Box<T>?

2. Rc::clone(&a) thật ra làm gì?

3. RefCell<T> kiểm tra quy tắc mượn vào lúc nào?

4. Rc<RefCell<T>> cho bạn điều gì mà riêng Rc<T> không có?

Điểm: 0 / 4