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

Weak và Cow trong Rust

Ở trang Con trỏ thông minh, bạn đã gặp Box, Rc và RefCell. Trang này thêm hai công cụ nữa để giải quyết hai vấn đề rất thực tế. Weak<T> cho phép bạn trỏ tới một giá trị mà không giữ nó sống. Cow<T> cho phép một hàm trả về dữ liệu mượn khi có thể, và chỉ tạo bản sao khi bắt buộc.

Hãy coi Rc như chìa khóa nhà: chừng nào còn ai đó giữ chìa, ngôi nhà vẫn còn. Weak thì giống địa chỉ ghi trên một mảnh giấy. Bạn dùng nó để tìm tới ngôi nhà, nhưng nếu mọi người đều trả chìa và ngôi nhà không còn nữa, địa chỉ đó chỉ dẫn tới khoảng trống. Không ai bị kẹt phải trả tiền cho một ngôi nhà mà họ đã quên mất.

Vấn đề: vòng Rc không bao giờ được dọn dẹp

Phần tiêu đề “Vấn đề: vòng Rc không bao giờ được dọn dẹp”

Một giá trị Rc bị drop khi bộ đếm mạnh (strong count) của nó về 0. Cách đó chạy rất tốt cho tới khi hai giá trị giữ Rc trỏ vào nhau. Khi đó mỗi cái giữ cho cái kia sống mãi mãi.

Để thấy điều này, kiểu Person bên dưới triển khai Drop, nên nó in một thông báo khi được dọn dẹp. Hãy xem những thông báo nào xuất hiện:

use std::cell::RefCell;
use std::rc::Rc;
struct Person {
name: String,
friend: RefCell<Option<Rc<Person>>>,
}
impl Drop for Person {
fn drop(&mut self) {
println!("đang giải phóng {}", self.name);
}
}
fn main() {
let alice = Rc::new(Person { name: String::from("alice"), friend: RefCell::new(None) });
let bob = Rc::new(Person { name: String::from("bob"), friend: RefCell::new(None) });
// alice trỏ tới bob, và bob trỏ ngược lại alice
*alice.friend.borrow_mut() = Some(Rc::clone(&bob));
*bob.friend.borrow_mut() = Some(Rc::clone(&alice));
println!("strong count của alice: {}", Rc::strong_count(&alice));
println!("strong count của bob: {}", Rc::strong_count(&bob));
println!("hết main");
}

Kết quả:

strong count của alice: 2
strong count của bob: 2
hết main
▶ Tự chạy thử

Để ý thứ bị thiếu: không có đang giải phóng alice và cũng không có đang giải phóng bob. Khi main kết thúc, hai biến alice và bob biến mất, nên mỗi bộ đếm giảm từ 2 xuống 1. Nhưng nó không bao giờ về 0, vì mỗi người vẫn đang được giữ bởi trường friend của người kia. Vùng nhớ đó bị rò rỉ (leak).

Đừng lo nếu điều này làm bạn bất ngờ. Trình biên dịch không bắt được nó, và nó cũng không phải một lần sập chương trình. Đó chỉ là vùng nhớ không bao giờ được trả lại. Trong một chương trình chạy lâu và liên tục tạo ra các vòng như vậy, nó sẽ cộng dồn.

Weak<T>: một con trỏ không giữ cho giá trị sống

Phần tiêu đề “Weak<T>: một con trỏ không giữ cho giá trị sống”

Cách sửa là biến một hoặc cả hai liên kết thành yếu (weak). Bạn lấy một Weak từ một Rc bằng Rc::downgrade. Một liên kết yếu không được tính là chủ sở hữu, nên nó không ngăn giá trị bị drop.

Vì giá trị có thể đã không còn, bạn không dùng trực tiếp một Weak được. Bạn gọi .upgrade(), hàm này cho bạn một Option<Rc<T>>: Some nếu giá trị vẫn còn sống, None nếu không.

use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Person {
name: String,
friend: RefCell<Weak<Person>>,
}
impl Drop for Person {
fn drop(&mut self) {
println!("đang giải phóng {}", self.name);
}
}
fn main() {
let alice = Rc::new(Person { name: String::from("alice"), friend: RefCell::new(Weak::new()) });
let bob = Rc::new(Person { name: String::from("bob"), friend: RefCell::new(Weak::new()) });
*alice.friend.borrow_mut() = Rc::downgrade(&bob);
*bob.friend.borrow_mut() = Rc::downgrade(&alice);
if let Some(friend) = alice.friend.borrow().upgrade() {
println!("bạn của alice là {}", friend.name);
}
println!("alice: strong = {}, weak = {}", Rc::strong_count(&alice), Rc::weak_count(&alice));
println!("hết main");
}

Kết quả:

bạn của alice là bob
alice: strong = 1, weak = 1
hết main
đang giải phóng bob
đang giải phóng alice
▶ Tự chạy thử

Lần này cả hai dòng đang giải phóng đều xuất hiện. Alice có một chủ sở hữu mạnh (biến alice) và một liên kết yếu (từ Bob). Khi main kết thúc, bộ đếm mạnh về 0, và Rust dọn dẹp cô ấy. Bob được dọn trước vì các biến bị drop theo thứ tự ngược với lúc tạo.

Vài điều cần nhớ:

  • Weak::new() tạo một con trỏ yếu rỗng, không trỏ vào đâu cả. upgrade() trên nó luôn trả về None. Đó là một giá trị khởi đầu tiện lợi.
  • Rc::strong_count đếm số chủ sở hữu. Rc::weak_count đếm số liên kết yếu. Chỉ có bộ đếm mạnh quyết định khi nào giá trị bị drop.
  • Rc bạn nhận lại từ upgrade() là một chủ sở hữu thật, tạm thời. Khi nó ra khỏi phạm vi, bộ đếm mạnh lại giảm xuống.

Cây thư mục: con được sở hữu, cha là liên kết yếu

Phần tiêu đề “Cây thư mục: con được sở hữu, cha là liên kết yếu”

Cây là chỗ kinh điển để dùng Weak. Một thư mục sở hữu các thư mục con của nó, nên children chứa Rc. Một thư mục con cũng muốn biết cha của nó, nhưng không nên sở hữu cha. Nếu cả hai chiều đều dùng Rc, bạn lại có một vòng. Vì vậy parent là một Weak:

use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Folder {
name: String,
parent: RefCell<Weak<Folder>>,
children: RefCell<Vec<Rc<Folder>>>,
}
fn new_folder(name: &str) -> Rc<Folder> {
Rc::new(Folder {
name: name.to_string(),
parent: RefCell::new(Weak::new()),
children: RefCell::new(Vec::new()),
})
}
fn add_child(parent: &Rc<Folder>, child: Rc<Folder>) {
*child.parent.borrow_mut() = Rc::downgrade(parent);
parent.children.borrow_mut().push(child);
}
fn full_path(folder: &Folder) -> String {
match folder.parent.borrow().upgrade() {
Some(parent) => format!("{}/{}", full_path(&parent), folder.name),
None => folder.name.clone(),
}
}
fn main() {
let home = new_folder("home");
let photos = new_folder("photos");
let trips = new_folder("trips");
add_child(&home, Rc::clone(&photos));
add_child(&photos, Rc::clone(&trips));
match photos.parent.borrow().upgrade() {
Some(parent) => println!("photos nằm trong {}", parent.name),
None => println!("photos không có thư mục cha"),
}
println!("đường dẫn đầy đủ: {}", full_path(&trips));
println!("home: strong = {}, weak = {}", Rc::strong_count(&home), Rc::weak_count(&home));
println!("photos: strong = {}, weak = {}", Rc::strong_count(&photos), Rc::weak_count(&photos));
}

Kết quả:

photos nằm trong home
đường dẫn đầy đủ: home/photos/trips
home: strong = 1, weak = 1
photos: strong = 2, weak = 1
▶ Tự chạy thử

Hãy đọc chậm các con số. photos có hai chủ sở hữu mạnh: biến photos và danh sách children bên trong home. Nó có một liên kết yếu: trường parent của trips. home chỉ có một chủ sở hữu mạnh, là chính biến của nó, vì không ai sở hữu cha của mình. Đó đúng là hình dạng bạn muốn: quyền sở hữu chảy xuống dưới cây, còn các liên kết đi lên là liên kết yếu.

RefCell có mặt ở đây vì bạn gán parent và đẩy vào children sau khi các thư mục đã nằm trong một Rc, mà riêng Rc chỉ cho bạn quyền truy cập dùng chung, chỉ đọc.

Danh sách Weak không giữ cho mọi thứ sống

Phần tiêu đề “Danh sách Weak không giữ cho mọi thứ sống”

Weak cũng rất hợp cho các danh sách kiểu “theo dõi những thứ này, nhưng đừng giữ chúng lại”: listener, observer, hoặc một cache chứa những thứ người khác sở hữu. Ở đây, điểm số của một trò chơi gửi cập nhật cho những ai vẫn còn lắng nghe. Khi một listener bị drop, liên kết yếu của nó đơn giản là ngừng hoạt động:

use std::rc::{Rc, Weak};
struct Listener {
name: String,
}
fn notify(listeners: &[Weak<Listener>], event: &str) {
for weak in listeners {
match weak.upgrade() {
Some(listener) => println!("{} nhận được: {event}", listener.name),
None => println!("(một listener đã không còn, bỏ qua)"),
}
}
}
fn main() {
let screen = Rc::new(Listener { name: String::from("screen") });
let logger = Rc::new(Listener { name: String::from("logger") });
let mut listeners: Vec<Weak<Listener>> = Vec::new();
listeners.push(Rc::downgrade(&screen));
listeners.push(Rc::downgrade(&logger));
notify(&listeners, "điểm là 10");
drop(logger); // chủ sở hữu đóng logger lại
notify(&listeners, "điểm là 20");
// dọn các liên kết không còn trỏ vào đâu
listeners.retain(|weak| weak.upgrade().is_some());
println!("số listener còn lại: {}", listeners.len());
}

Kết quả:

screen nhận được: điểm là 10
logger nhận được: điểm là 10
screen nhận được: điểm là 20
(một listener đã không còn, bỏ qua)
số listener còn lại: 1
▶ Tự chạy thử

Danh sách này không hề ngăn logger bị drop. Nếu nó giữ Rc, logger sẽ tiếp tục sống bên trong danh sách, vẫn nhận những tin nhắn mà chẳng ai cần. Với Weak, chủ sở hữu quyết định khi nào một thứ biến mất, còn những người khác chỉ cần kiểm tra bằng upgrade().

Cow: mượn khi có thể, sao chép khi bắt buộc

Phần tiêu đề “Cow: mượn khi có thể, sao chép khi bắt buộc”

Giờ là một vấn đề khác. Giả sử bạn viết một hàm chuẩn hóa tên người dùng. Hầu hết tên đều đã ổn. Một vài tên có khoảng trắng thừa hoặc chữ hoa. Nếu hàm của bạn luôn trả về một String mới, bạn sẽ sao chép mọi cái tên, kể cả những cái bạn không thay đổi.

Cow (“clone on write”, sao chép khi ghi, từ std::borrow::Cow) giải quyết chuyện này. Một Cow<'_, str> là một enum có hai biến thể (variant):

  • Cow::Borrowed(&str): “đây là văn bản gốc, còn nguyên”. Không cấp phát gì cả.
  • Cow::Owned(String): “tôi đã phải thay đổi nó, nên đây là một String mới”.

Các tên đầu vào được giữ bằng tiếng Anh vì hàm dùng .len() (đếm byte) và đổi chữ hoa/thường.

use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> {
let trimmed = name.trim();
let has_upper = name.chars().any(|c| c.is_uppercase());
if trimmed.len() == name.len() && !has_upper {
Cow::Borrowed(name) // đã sạch: trả lại bản gốc
} else {
Cow::Owned(trimmed.to_lowercase()) // cần sửa: tạo một String mới
}
}
fn main() {
for input in ["alice", " Bob ", "CAROL", "dave"] {
match normalize(input) {
Cow::Borrowed(name) => println!("{input:?} -> {name:?} (mượn, không sao chép)"),
Cow::Owned(name) => println!("{input:?} -> {name:?} (sở hữu, String mới)"),
}
}
}

Kết quả:

"alice" -> "alice" (mượn, không sao chép)
" Bob " -> "bob" (sở hữu, String mới)
"CAROL" -> "carol" (sở hữu, String mới)
"dave" -> "dave" (mượn, không sao chép)
▶ Tự chạy thử

Hai trong bốn cái tên không tốn chút gì. Trong một chương trình thật mà 99% dữ liệu vào đã sạch sẵn, đó là rất nhiều lần sao chép bạn không bao giờ phải làm. Dấu '_ trong Cow<'_, str> chỉ có nghĩa là “cái này có thể mượn từ đầu vào”. Nếu lifetime (vòng đời) vẫn còn mơ hồ với bạn, trang Lifetime sẽ giải thích.

Thường bạn không cần match trên một Cow. Nó deref thành &str, nên bạn có thể gọi các phương thức chuỗi trên nó và truyền &name vào bất kỳ hàm nào nhận &str. Khi bạn thật sự cần một String của riêng mình, hãy gọi .into_owned(). Nó lấy String ra nếu có, và chỉ sao chép nếu Cow đang ở dạng mượn.

use std::borrow::Cow;
fn normalize(name: &str) -> Cow<'_, str> {
let trimmed = name.trim();
if trimmed.len() == name.len() && !name.chars().any(|c| c.is_uppercase()) {
Cow::Borrowed(name)
} else {
Cow::Owned(trimmed.to_lowercase())
}
}
fn greet(name: &str) {
println!("Chào mừng, {name}!");
}
fn main() {
let name = normalize(" Eve ");
println!("độ dài: {}", name.len()); // gọi thẳng các phương thức của str
println!("bắt đầu bằng e: {}", name.starts_with('e'));
greet(&name); // &Cow<str> tự thành &str
let saved: String = name.into_owned(); // giờ nó là một String bình thường
println!("đã lưu {saved:?} vào danh sách người dùng");
}

Kết quả:

độ dài: 3
bắt đầu bằng e: true
Chào mừng, eve!
đã lưu "eve" vào danh sách người dùng
▶ Tự chạy thử

Cow cũng dùng được với các kiểu khác, như Cow<'_, [i32]> cho một slice có thể cần thay đổi. Nhưng với văn bản, Cow<'_, str> là thứ bạn sẽ gặp nhiều nhất.

Đây là bảng tóm tắt ngắn cho mọi kiểu “na ná con trỏ” mà bạn đã gặp tới giờ:

Kiểu Nó cho bạn gì Dùng khi
Box<T> Một chủ sở hữu, giá trị nằm trên heap Kiểu đệ quy, hoặc một giá trị lớn bạn muốn di chuyển rẻ
Rc<T> Nhiều chủ sở hữu, một luồng Nhiều phần của chương trình dùng chung một dữ liệu
Arc<T> Nhiều chủ sở hữu, giữa các luồng Giống Rc, nhưng chia sẻ giữa các luồng (xem Trạng thái dùng chung)
Weak<T> Một liên kết không sở hữu Liên kết tới cha, danh sách observer, cache, phá vòng Rc
RefCell<T> Thay đổi qua một tham chiếu dùng chung, kiểm tra lúc chạy Bạn cần thay đổi thứ gì đó nằm bên trong một Rc
Cow<'_, T> Mượn hoặc sở hữu, quyết định lúc chạy Một hàm thường trả về đầu vào mà không thay đổi

Nếu không chắc, hãy bắt đầu với ownership và borrowing thông thường. Chỉ thêm những kiểu này khi trình biên dịch, hoặc một vụ rò rỉ bộ nhớ, cho bạn biết là bạn cần chúng.

Unwrap một upgrade sau khi giá trị đã không còn

Phần tiêu đề “Unwrap một upgrade sau khi giá trị đã không còn”

upgrade() trả về một Option là có lý do. Nếu bạn .unwrap() nó theo thói quen, chương trình sẽ panic ngay khi giá trị đã bị drop:

use std::rc::Rc;
fn main() {
let session = Rc::new(String::from("user-42"));
let link = Rc::downgrade(&session);
drop(session); // phiên đăng nhập kết thúc
let current = link.upgrade().unwrap();
println!("vẫn đang đăng nhập với tên {current}");
}

Khi chạy, bạn nhận được:

thread 'main' (12345) panicked at src/main.rs:9:34:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
▶ Tự chạy thử

Thông báo panic (bằng tiếng Anh) nói rằng bạn đã gọi unwrap() trên một giá trị None. Cách sửa là coi “nó đã không còn” là một trường hợp bình thường, với match hoặc if let:

match link.upgrade() {
Some(current) => println!("vẫn đang đăng nhập với tên {current}"),
None => println!("phiên đăng nhập đã kết thúc"),
}

Nếu một con giữ Rc<Parent> và cha giữ Rc<Child>, bạn đã tạo ra đúng cái vòng ở phần đầu. Không có gì sập, cũng không có cảnh báo nào; vùng nhớ đơn giản là không bao giờ được trả lại. Một quy tắc tốt: quyết định ai sở hữu ai, dùng Rc theo chiều đó, và Weak cho mọi liên kết đi theo chiều ngược lại.

struct Folder {
parent: RefCell<Rc<Folder>>, // sai: con giữ cho cha sống
children: RefCell<Vec<Rc<Folder>>>,
}

Đổi parent thành RefCell<Weak<Folder>> là vòng biến mất.

1. Bình luận trên một bài viết đã bị xóa

Phần tiêu đề “1. Bình luận trên một bài viết đã bị xóa”

Tạo một Post có title, được giữ trong một Rc. Tạo một Comment có text và post: Weak<Post>. Viết hàm describe(comment: &Comment) in ra "<text>" trong bài <title> nếu bài viết vẫn còn, hoặc "<text>" trong một bài đã bị xóa nếu không. Gọi nó, drop bài viết, rồi gọi lại. Gợi ý: dùng match comment.post.upgrade().

Xem lời giải
use std::rc::{Rc, Weak};
struct Post {
title: String,
}
struct Comment {
text: String,
post: Weak<Post>,
}
fn describe(comment: &Comment) {
match comment.post.upgrade() {
Some(post) => println!("\"{}\" trong bài {}", comment.text, post.title),
None => println!("\"{}\" trong một bài đã bị xóa", comment.text),
}
}
fn main() {
let post = Rc::new(Post { title: String::from("Học Rust") });
let comment = Comment { text: String::from("Bài viết hay quá!"), post: Rc::downgrade(&post) };
describe(&comment);
drop(post);
describe(&comment);
}

Kết quả:

"Bài viết hay quá!" trong bài Học Rust
"Bài viết hay quá!" trong một bài đã bị xóa
▶ Tự chạy thử

Bình luận không bao giờ giữ cho bài viết sống. Khi Rc duy nhất bị drop, upgrade() trả về None và bình luận xử lý nó một cách bình tĩnh thay vì panic.

Viết fn escape(text: &str) -> Cow<'_, str> để thay mọi dấu < bằng &lt;. Nếu văn bản không có <, trả về nó ở dạng mượn. Thử với "xin chào" và "1 < 2", rồi in ra bạn nhận được biến thể nào. Gợi ý: kiểm tra bằng text.contains('<') trước, rồi dùng text.replace('<', "&lt;") cho trường hợp sở hữu.

Xem lời giải
use std::borrow::Cow;
fn escape(text: &str) -> Cow<'_, str> {
if text.contains('<') {
Cow::Owned(text.replace('<', "&lt;"))
} else {
Cow::Borrowed(text)
}
}
fn main() {
for text in ["xin chào", "1 < 2"] {
match escape(text) {
Cow::Borrowed(s) => println!("{s} (mượn)"),
Cow::Owned(s) => println!("{s} (sở hữu)"),
}
}
}

Kết quả:

xin chào (mượn)
1 &lt; 2 (sở hữu)
▶ Tự chạy thử

Chỉ văn bản thật sự chứa < mới phải trả giá cho một String mới. Mọi thứ khác đi thẳng qua, còn nguyên vẹn.

  • Hai giá trị Rc trỏ vào nhau không bao giờ có bộ đếm mạnh về 0, nên chúng bị rò rỉ và Drop của chúng không bao giờ chạy.
  • Rc::downgrade tạo ra một Weak<T>, thứ không giữ cho giá trị sống. upgrade() trả lại Option<Rc<T>>.
  • Trong cây, con được sở hữu bằng Rc còn liên kết tới cha là Weak. Quyền sở hữu chảy xuống, liên kết yếu trỏ lên.
  • Danh sách observer và cache có thể giữ Weak để không bao giờ vô tình giữ cho thứ gì đó sống.
  • Cow<'_, str> trả về văn bản mượn khi không có gì thay đổi, và chỉ trả về một String sở hữu khi có thay đổi. Nó deref thành &str, và .into_owned() cho bạn một String.

Kiểm tra nhanh

1. Hai giá trị mỗi cái giữ một Rc trỏ tới cái kia. Điều gì xảy ra khi main kết thúc?

2. weak.upgrade() trả về gì?

3. Trong một cây thư mục, liên kết nào nên là Weak?

4. Khi nào một hàm trả về Cow<'_, str> tránh được việc tạo String mới?

Điểm: 0 / 4