Fn, FnMut và FnOnce trong Rust
Trong bài Closure, bạn đã học cách viết closure, cách nó bắt biến và cách nó thay đổi biến. Bài này trả lời câu hỏi tiếp theo: làm sao để viết một hàm của riêng bạn nhận vào một closure? Và làm sao để trả một closure về từ một hàm?
Câu trả lời là ba trait nhỏ: Fn, FnMut và FnOnce. Hãy hình dung chúng như nội quy của một thư viện. Có người chỉ đọc sách (Fn). Có người ghi chú bên lề sách (FnMut). Và có người mang sách về nhà rồi không bao giờ trả lại (FnOnce). Rust nhìn xem closure của bạn làm gì, rồi gắn cho nó cái nhãn phù hợp. Nếu lúc đầu các trait này trông đáng sợ, đừng lo. Chỉ có ba thôi, và trình biên dịch tự chọn đúng cái cho bạn.
Vì sao closure lại cần trait
Phần tiêu đề “Vì sao closure lại cần trait”Mỗi closure có một kiểu riêng duy nhất do trình biên dịch tự tạo ra. Bạn không thể viết ra tên của kiểu đó. Ngay cả hai closure trông giống hệt nhau cũng có kiểu khác nhau:
fn main() { let mut rules = vec![|price: u32| price * 2]; rules.push(|price: u32| price * 2);}error[E0308]: mismatched types --> src/main.rs:3:16 |3 | rules.push(|price: u32| price * 2); | ---- ^^^^^^^^^^^^^^^^^^^^^^ expected closure, found a different closure | = note: no two closures, even if identical, have the same type = help: consider boxing your closure and/or using it as a trait objectTrình biên dịch nói rằng nó mong đợi closure thứ nhất nhưng lại nhận một closure khác: không có hai closure nào cùng kiểu, kể cả khi chúng giống hệt nhau.
Vì vậy một hàm không thể nói “đưa tôi một closure có kiểu X”. Thay vào đó, nó nói “đưa tôi bất kỳ kiểu nào gọi được theo cách này”. Đó là một ràng buộc trait (trait bound), giống như những gì bạn thấy trong bài Generic và Trait:
fn price_after<F: Fn(u32) -> u32>(rule: F, price: u32) -> u32 { rule(price)}
fn main() { let coupon = 15; let half_price = |price: u32| price / 2; let use_coupon = |price: u32| price - coupon;
println!("{}", price_after(half_price, 80)); println!("{}", price_after(use_coupon, 80));}Kết quả:
4065Hãy đọc F: Fn(u32) -> u32 là: “F là một kiểu nào đó mà bạn gọi được với một u32, và nó trả về một u32.” Cả hai closure đều khớp, dù kiểu của chúng khác nhau.
Ba trait: Fn, FnMut và FnOnce
Phần tiêu đề “Ba trait: Fn, FnMut và FnOnce”Trình biên dịch xem thân closure làm gì với các giá trị nó đã bắt:
Fn: nó chỉ đọc chúng. Bạn gọi bao nhiêu lần tùy thích.FnMut: nó thay đổi chúng. Bạn gọi được nhiều lần, nhưng cầnmut.FnOnce: nó cho đi chúng (move ra ngoài). Bạn chỉ gọi được một lần, vì sau đó giá trị đã không còn.
fn main() { let shop = String::from("Tiệm bánh Góc Phố");
// Fn: chỉ đọc `shop` let greet = || println!("Chào mừng đến {shop}!"); greet(); greet();
// FnMut: thay đổi `orders` let mut orders = 0; let mut take_order = || { orders += 1; println!("đã nhận đơn #{orders}"); }; take_order(); take_order();
// FnOnce: trao `receipt` lại cho nơi gọi let receipt = String::from("2 bánh sừng bò, 1 cà phê"); let hand_over = || receipt; let customer_copy = hand_over(); println!("khách cầm: {customer_copy}");}Kết quả:
Chào mừng đến Tiệm bánh Góc Phố!Chào mừng đến Tiệm bánh Góc Phố!đã nhận đơn #1đã nhận đơn #2khách cầm: 2 bánh sừng bò, 1 cà phêhand_over trả về chính receipt, không phải một bản sao. Khi hóa đơn đã được trao đi, closure không còn gì để đưa nữa. Vì thế nó là FnOnce.
Đây là hình ảnh bạn nên giữ trong đầu. Hãy tưởng tượng ba vòng tròn lồng vào nhau:
- Vòng tròn lớn bên ngoài là
FnOnce. Mọi closure đều gọi được ít nhất một lần, nên mọi closure đều nằm trong này. - Bên trong là một vòng nhỏ hơn,
FnMut: những closure gọi được hết lần này đến lần khác. - Ở chính giữa là
Fn: những closure gọi được hết lần này đến lần khác mà không thay đổi gì cả.
Vì vậy mọi closure Fn cũng là FnMut và FnOnce. Điều này quan trọng khi bạn viết hàm. Một tham số đòi FnOnce chấp nhận mọi closure. Một tham số đòi Fn là khó tính nhất.
Có một điều khiến nhiều người bất ngờ: từ khóa move không quyết định trait. move || println!("{shop}") lấy quyền sở hữu shop, nhưng chỉ đọc nó, nên vẫn là Fn. Chỉ có thân closure quyết định.
Viết hàm nhận closure
Phần tiêu đề “Viết hàm nhận closure”Có ba cách viết cùng một ràng buộc. Chúng đều có nghĩa như nhau, nên hãy chọn cách nào bạn thấy dễ đọc nhất:
// 1. Một tham số generic kèm ràng buộcfn apply<F: Fn(i32) -> i32>(f: F, x: i32) -> i32 { f(x)}
// 2. `impl Fn` ở vị trí tham số: ngắn hơn, không cần đặt tênfn apply_twice(f: impl Fn(i32) -> i32, x: i32) -> i32 { f(f(x))}
// 3. Mệnh đề `where`: gọn gàng khi ràng buộc dàifn apply_to_all<F>(f: F, scores: &[i32]) -> Vec<i32>where F: Fn(i32) -> i32,{ scores.iter().map(|&s| f(s)).collect()}
fn main() { let bonus = 10; let add_bonus = |score: i32| score + bonus;
println!("{}", apply(add_bonus, 50)); println!("{}", apply_twice(add_bonus, 50)); println!("{:?}", apply_to_all(add_bonus, &[40, 65, 90]));}Kết quả:
6070[50, 75, 100]Có thể bạn thắc mắc vì sao ta truyền được add_bonus tới ba lần. Một closure chỉ đọc những gì nó bắt thì sao chép rất rẻ, nên Rust sao chép nó mỗi lần.
Giờ đến FnMut và FnOnce. Với FnMut, tham số cần mut, vì gọi closure sẽ thay đổi những gì bên trong nó. Với FnOnce, bạn gọi nó một lần, vậy là hết:
fn process_tasks<F: FnMut(&str)>(tasks: &[&str], mut on_done: F) { for task in tasks { on_done(task); }}
fn on_shutdown<F: FnOnce()>(cleanup: F) { println!("đang tắt..."); cleanup();}
fn main() { let mut finished = Vec::new(); process_tasks(&["gửi email", "sao lưu cơ sở dữ liệu"], |task| { finished.push(task.to_string()); }); println!("đã xong: {:?}", finished);
on_shutdown(|| { // Vòng lặp này lấy quyền sở hữu `finished`, nên closure là FnOnce for task in finished { println!("đã lưu trữ: {task}"); } });}Kết quả:
đã xong: ["gửi email", "sao lưu cơ sở dữ liệu"]đang tắt...đã lưu trữ: gửi emailđã lưu trữ: sao lưu cơ sở dữ liệuMột quy tắc dễ nhớ: hãy đòi trait dễ tính nhất mà hàm của bạn vẫn dùng được. Nếu bạn chỉ gọi closure một lần, hãy đòi FnOnce, và nơi gọi có thể truyền vào bất cứ thứ gì. Bạn cũng sẽ thấy điều này trong thư viện chuẩn: Option::map nhận FnOnce, còn Iterator::map nhận FnMut.
Khi bạn chọn sai ràng buộc
Phần tiêu đề “Khi bạn chọn sai ràng buộc”Trình biên dịch bắt được mọi chỗ không khớp, và thông báo của nó ở đây rất thân thiện. Giả sử một hàm đòi Fn, nhưng closure của bạn lại thay đổi một bộ đếm:
fn run_report<F: Fn()>(f: F) { f(); f();}
fn main() { let mut runs = 0; run_report(|| { runs += 1; println!("report run {runs}"); });}error[E0594]: cannot assign to `runs`, as it is a captured variable in a `Fn` closure --> src/main.rs:9:9 |1 | fn run_report<F: Fn()>(f: F) { | - change this to accept `FnMut` instead of `Fn`...8 | run_report(|| { | ---------- -- in this closure | | | expects `Fn` instead of `FnMut`9 | runs += 1; | ^^^^^^^^^ cannot assignThông báo (bằng tiếng Anh, như rustc in ra) nói rằng không thể gán cho runs vì nó là biến được bắt trong một closure Fn. Nó còn chỉ luôn cách sửa: nhận FnMut, và thêm mut vào tham số. Với thay đổi đó, chương trình in ra report run 1 và report run 2:
fn run_report<F: FnMut()>(mut f: F) { f(); f();}Lỗi kinh điển còn lại là gọi một closure FnOnce hai lần:
fn main() { let report = String::from("Q3 sales"); let send = || { let sent = report; println!("sending {sent}"); }; send(); send();}error[E0382]: use of moved value: `send` --> src/main.rs:8:5 |7 | send(); | ------ `send` moved due to this call8 | send(); | ^^^^ value used here after move |note: closure cannot be invoked more than once because it moves the variable `report` out of its environment --> src/main.rs:4:20 |4 | let sent = report; | ^^^^^^Phần note nói rất rõ: closure không thể gọi nhiều hơn một lần vì nó move biến report ra khỏi môi trường của mình. Lần gọi đầu tiên đã cho report đi, nên chẳng còn gì cho lần gọi thứ hai. Nếu thật sự cần gọi hai lần, hãy mượn thay vì lấy (let sent = &report;) và closure sẽ trở thành Fn. Nếu bạn truyền một closure như vậy vào hàm đòi Fn, bạn sẽ nhận thông báo tương tự: error[E0507]: cannot move out of report, a captured variable in an Fn closure.
Trả closure về với impl Fn
Phần tiêu đề “Trả closure về với impl Fn”Một hàm cũng có thể tạo ra một closure và trả nó về. Bạn viết kiểu trả về là impl Fn(i32) -> i32, nghĩa là “một kiểu closure nào đó; bạn không cần biết tên của nó”:
fn make_adder(amount: i32) -> impl Fn(i32) -> i32 { move |x| x + amount}
fn make_multiplier(factor: i32) -> impl Fn(i32) -> i32 { move |x| x * factor}
fn main() { let add_tip = make_adder(5); let triple = make_multiplier(3);
println!("{}", add_tip(20)); println!("{}", triple(7)); println!("{}", add_tip(triple(10)));}Kết quả:
252135Vì sao cần move? Nếu không có nó, closure chỉ mượn amount. Nhưng amount thuộc về make_adder, và nó biến mất ngay khi make_adder trả về. Closure sẽ giữ một tham chiếu trỏ vào hư không. Rust từ chối:
error[E0373]: closure may outlive the current function, but it borrows `amount`, which is owned by the current function --> src/main.rs:2:5 |2 | |x| x + amount | ^^^ ------ `amount` is borrowed here | | | may outlive borrowed value `amount` |help: to force the closure to take ownership of `amount` (and any other referenced variables), use the `move` keyword |2 | move |x| x + amount | ++++Rustc báo rằng closure có thể sống lâu hơn hàm hiện tại trong khi lại mượn amount, và gợi ý dùng từ khóa move. Với move, closure gói theo bản sao amount của riêng nó và mang đi cùng, giống như mang theo hộp cơm của mình thay vì để nó trong tủ lạnh nhà người khác.
Closure đóng hộp: nhiều closure, một kiểu
Phần tiêu đề “Closure đóng hộp: nhiều closure, một kiểu”impl Fn đại diện cho một kiểu ẩn. Vì thế bạn không thể trả về một closure ở nhánh if và một closure khác ở nhánh else. Bạn cũng không thể đặt các closure khác nhau vào cùng một Vec. Cho cả hai việc đó, hãy đặt mỗi closure vào một cái hộp: Box<dyn Fn(f64) -> f64> nghĩa là “bất kỳ closure nào có hình dạng này, được lưu trên heap”. Đây cũng là ý tưởng trong bài Trait object.
Đây là một quầy thanh toán áp dụng lần lượt một danh sách quy tắc giá có tên:
fn shipping_rule(express: bool, fee: f64) -> Box<dyn Fn(f64) -> f64> { if express { Box::new(move |total| total + fee * 2.0) } else { Box::new(move |total| total + fee) }}
fn main() { let member_percent = 10.0; let coupon = 5.0;
let rules: Vec<(&str, Box<dyn Fn(f64) -> f64>)> = vec![ ("giảm giá thành viên", Box::new(move |total: f64| total * (1.0 - member_percent / 100.0))), ("phiếu giảm giá", Box::new(move |total: f64| total - coupon)), ("phí giao hàng thường", shipping_rule(false, 3.5)), ];
let mut total = 80.0; println!("ban đầu: {total:.2}"); for (name, rule) in &rules { total = rule(total); println!("sau {name}: {total:.2}"); }}Kết quả:
ban đầu: 80.00sau giảm giá thành viên: 72.00sau phiếu giảm giá: 67.00sau phí giao hàng thường: 70.50Đóng hộp tốn một lần cấp phát nhỏ trên heap, và mỗi lần gọi phải đi qua một con trỏ. Điều đó hầu như không bao giờ là vấn đề. Dùng impl Fn khi chỉ có một kiểu closure, và Box<dyn Fn> khi có nhiều.
Lưu closure trong struct
Phần tiêu đề “Lưu closure trong struct”Một struct cũng có thể chứa closure. Vì bạn không thể gọi tên kiểu của closure, bạn cho struct trở thành generic theo kiểu đó. Đây là một cái nút nhỏ chạy một đoạn code khi được nhấn:
struct Button<F: Fn()> { label: String, on_click: F,}
impl<F: Fn()> Button<F> { fn click(&self) { println!("[{}] được nhấn", self.label); (self.on_click)(); }}
fn main() { let user = String::from("Ada"); let save = Button { label: String::from("Lưu"), on_click: move || println!("đang lưu bản nháp của {user}"), };
save.click(); save.click();}Kết quả:
[Lưu] được nhấnđang lưu bản nháp của Ada[Lưu] được nhấnđang lưu bản nháp của AdaĐể ý cặp ngoặc tròn trong (self.on_click)(). Chúng bảo Rust “lấy field ra, rồi gọi nó”. Nếu cái nút cần thay đổi những gì nó bắt ở mỗi lần nhấn, hãy dùng F: FnMut() và cho phương thức nhận &mut self.
Con trỏ hàm: fn(i32) -> i32
Phần tiêu đề “Con trỏ hàm: fn(i32) -> i32”Hàm thông thường cũng truyền đi được. Kiểu của chúng được viết bằng chữ fn thường, như fn(i32) -> i32. Đây là một con trỏ hàm (function pointer), chỉ đơn giản là địa chỉ của một đoạn code. Một closure không bắt gì cả cũng có thể trở thành con trỏ hàm:
fn double(x: i32) -> i32 { x * 2}
fn square(x: i32) -> i32 { x * x}
fn apply_op(op: fn(i32) -> i32, x: i32) -> i32 { op(x)}
fn main() { println!("{}", apply_op(double, 4)); println!("{}", apply_op(|x| x - 1, 4));
let steps: [fn(i32) -> i32; 3] = [double, square, |x| x + 1]; let mut value = 3; for step in steps { value = step(value); } println!("{value}");}Kết quả:
8337Các con trỏ hàm đều có chung một kiểu, nên bạn đặt chúng vào một mảng mà không cần đóng hộp. Điểm hạn chế là closure có bắt thứ gì đó thì không thể trở thành con trỏ hàm. Trình biên dịch sẽ nói: closures can only be coerced to fn types if they do not capture any variables (closure chỉ chuyển được thành kiểu fn nếu nó không bắt biến nào). Tin vui thì theo chiều ngược lại: mọi hàm thông thường cũng triển khai Fn, FnMut và FnOnce, nên bạn có thể truyền double vào hàm apply ở phần trước. Vì thế phần lớn code dùng các trait của closure: chúng chấp nhận cả hai.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Quên mut ở tham số FnMut. Gọi một FnMut sẽ thay đổi nó, nên binding của nó phải là mut:
error[E0596]: cannot borrow `on_done` as mutable, as it is not declared as mutable --> src/main.rs:3:9 |3 | on_done(task); | ^^^^^^^ cannot borrow as mutable |help: consider changing this to be mutable |1 | fn process_tasks<F: FnMut(&str)>(tasks: &[&str], mut on_done: F) { | +++Rustc báo rằng không thể mượn on_done theo kiểu khả biến vì nó không được khai báo mut, và chỉ ra đúng chỗ cần thêm.
Trả về các closure khác nhau từ -> impl Fn. Hai closure là hai kiểu, kể cả khi chúng trông giống nhau:
fn shipping_rule(express: bool, fee: f64) -> impl Fn(f64) -> f64 { if express { move |total| total + fee * 2.0 } else { move |total| total + fee }}error[E0308]: `if` and `else` have incompatible types --> src/main.rs:5:9 |2 | / if express {3 | | move |total| total + fee * 2.0 | | ------------------------------ | | | | | the expected closure | | expected because of this4 | | } else {5 | | move |total| total + fee | | ^^^^^^^^^^^^^^^^^^^^^^^^ expected closure, found a different closure6 | | } | |_____- `if` and `else` have incompatible types | = note: expected closure `{closure@src/main.rs:3:9: 3:21}` found closure `{closure@src/main.rs:5:9: 5:21}` = note: no two closures, even if identical, have the same type = help: consider boxing your closure and/or using it as a trait objecthelp: you could change the return type to be a boxed trait object |1 - fn shipping_rule(express: bool, fee: f64) -> impl Fn(f64) -> f64 {1 + fn shipping_rule(express: bool, fee: f64) -> Box<dyn Fn(f64) -> f64> { |help: if you change the return type to expect trait objects, box the returned expressions |3 ~ Box::new(move |total| total + fee * 2.0)4 | } else {5 ~ Box::new(move |total| total + fee) |Hãy làm theo gợi ý của trình biên dịch: trả về Box<dyn Fn(f64) -> f64> và bọc mỗi closure trong Box::new(...).
Gọi field closure như gọi phương thức. Viết self.on_click() khiến Rust đi tìm một phương thức tên là on_click:
error[E0599]: no method named `on_click` found for reference `&Button<F>` in the current scope --> src/main.rs:9:14 |9 | self.on_click(); | ^^^^^^^^ field, not a method |help: to call the type parameter stored in `on_click`, surround the field access with parentheses |9 | (self.on_click)(); | + +Rustc nhắc rằng on_click là field chứ không phải phương thức, và gợi ý bọc nó trong cặp ngoặc tròn.
Luyện tập
Phần tiêu đề “Luyện tập”1. Máy phát số thứ tự
Phần tiêu đề “1. Máy phát số thứ tự”Viết hàm ticket_dispenser(start: u32) trả về một closure. Mỗi lần gọi closure sẽ cho số thứ tự tiếp theo: start, rồi start + 1, và cứ thế. Tạo hai máy (một tiệm bánh bắt đầu từ 100, một nhà thuốc bắt đầu từ 1) và cho thấy chúng đếm riêng biệt. Gợi ý: closure thay đổi bộ đếm của chính nó, nên kiểu trả về là impl FnMut() -> u32, và bạn sẽ cần move.
Xem lời giải
fn ticket_dispenser(start: u32) -> impl FnMut() -> u32 { let mut next = start; move || { let ticket = next; next += 1; ticket }}
fn main() { let mut bakery = ticket_dispenser(100); let mut pharmacy = ticket_dispenser(1);
println!("tiệm bánh: {}", bakery()); println!("tiệm bánh: {}", bakery()); println!("nhà thuốc: {}", pharmacy()); println!("tiệm bánh: {}", bakery());}Kết quả:
tiệm bánh: 100tiệm bánh: 101nhà thuốc: 1tiệm bánh: 102Mỗi máy đã move next của riêng nó vào bên trong, nên chúng không bao giờ dùng chung bộ đếm. Các biến bakery và pharmacy cần mut vì chúng là FnMut.
2. Hàm thử lại
Phần tiêu đề “2. Hàm thử lại”Viết retry(max_attempts, task). Nó gọi task cho đến khi task trả về Ok, hoặc đến khi đã thử đủ max_attempts lần. In ra một dòng cho mỗi lần thử thất bại. Kiểm tra bằng một closure thất bại hai lần với "máy chủ đang bận", rồi thành công. Gợi ý: closure kiểm tra tự đếm số lần nó được gọi, nên ràng buộc là FnMut() -> Result<String, String>, và tham số cần mut.
Xem lời giải
fn retry<F>(max_attempts: u32, mut task: F) -> Result<String, String>where F: FnMut() -> Result<String, String>,{ for attempt in 1..=max_attempts { match task() { Ok(value) => return Ok(value), Err(e) => println!("lần thử {attempt} thất bại: {e}"), } } Err(String::from("bỏ cuộc"))}
fn main() { let mut calls = 0; let result = retry(5, || { calls += 1; if calls < 3 { Err(String::from("máy chủ đang bận")) } else { Ok(String::from("đã tải dữ liệu xong")) } }); println!("{:?} sau {calls} lần gọi", result);}Kết quả:
lần thử 1 thất bại: máy chủ đang bậnlần thử 2 thất bại: máy chủ đang bậnOk("đã tải dữ liệu xong") sau 3 lần gọiretry gọi task nhiều hơn một lần, nên FnOnce không được, và task thay đổi calls, nên Fn cũng không được. FnMut là vừa khít. Khi retry trả về, closure đã xong việc, nên main lại đọc được calls.
Tóm tắt
Phần tiêu đề “Tóm tắt”- Mỗi closure có kiểu riêng duy nhất, nên hàm nhận closure thông qua một ràng buộc trait như
F: Fn(i32) -> i32. Fnchỉ đọc những gì nó bắt,FnMutthay đổi chúng, cònFnOncemove chúng ra ngoài và chỉ gọi được một lần. Thân closure quyết định, không phảimove.- Mọi
Fncũng làFnMutvàFnOnce. Hãy đòi trait dễ tính nhất mà hàm của bạn cần. - Viết ràng buộc dạng
<F: Fn(..)>,impl Fn(..), hoặc trong mệnh đềwhere. Tham sốFnMutcầnmut. - Trả về một closure bằng
-> impl Fn(..)(thường kèmmove). DùngBox<dyn Fn(..)>cho các closure khác nhau từ các nhánh hoặc trong mộtVec. fn(i32) -> i32là một con trỏ hàm. Hàm thông thường và closure không bắt gì đều khớp với nó.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Một closure làm total += price trên một biến đã bắt. Trait khắt khe nhất mà nó triển khai là gì?
2. Hàm của bạn gọi tham số closure đúng một lần. Ràng buộc nào chấp nhận được nhiều closure nhất?
3. Vì sao make_adder(amount: i32) -> impl Fn(i32) -> i32 cần move |x| x + amount?
4. Bạn muốn một Vec chứa nhiều closure giảm giá khác nhau. Các phần tử cần có kiểu gì?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- Moving Captured Values Out of Closures and the Fn Traits — The Rust Book (tiếng Anh): lời giải thích chính thức về cách trình biên dịch chọn
Fn,FnMuthayFnOnce. Fn,FnMutvàFnOnce— tài liệu thư viện chuẩn (tiếng Anh): định nghĩa của từng trait, kèm ví dụ ngắn về khi nào nên dùng.- Input parameters — Rust by Example (tiếng Anh): các ví dụ chạy được về hàm nhận closure với từng ràng buộc trong ba loại.
- Output parameters — Rust by Example (tiếng Anh): trả closure về bằng
impl Fn, và vì sao cầnmove. - Closure types — The Rust Reference (tiếng Anh): các quy tắc chính xác về cách closure bắt giá trị và những trait mà nó triển khai.