Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Biết cách khai báo function nhận closure qua generic với
F: Fn(...) -> ...bound, và biến thểFnMut/FnOncetương ứng. - Biết cú pháp
impl Fn(...) -> ...ở position tham số là syntactic sugar cho generic — ngắn hơn nhưng hạn chế hơn (không gọi tên type được). - Hiểu cả hai cú pháp đều dùng static dispatch: compiler monomorphize function riêng cho mỗi concrete closure type — nhanh nhưng phình binary size.
- Biết pattern phổ biến nhất là iterator combinator (
map,filter,fold) — đều ănFnMutclosure và trả iterator mới. - Tự viết được higher-order function:
retrynhậnFnMutđể retry tới khi thành công,pipenhận haiFnđộc lập để compose. - Biết preview của return-position
impl Fnđể trả closure (đào sâu ở Bài 200).
Bài yêu cầu nắm chắc generic + trait bound (Nhóm 18), ba trait closure Fn / FnMut / FnOnce (Bài 196). Bài 198 sẽ học từ khoá move để ép closure lấy ownership, hữu ích đặc biệt khi spawn thread.
Function Nhận Closure — Generic F
Mỗi closure trong Rust có một concrete type ẩn danh do compiler sinh tự động — không thể viết ra được, mỗi closure literal là một type khác nhau dù signature giống hệt. Vì lẽ đó, function muốn nhận closure phải khai báo qua generic type parameter có trait bound trỏ tới một trong ba trait Fn, FnMut, FnOnce.
fn apply<F: Fn(i32) -> i32>(f: F) -> i32 {
f(10)
}
fn main() {
let double = |x| x * 2;
let add_one = |x| x + 1;
println!("{}", apply(double)); // 20
println!("{}", apply(add_one)); // 11
}
Cú pháp F: Fn(i32) -> i32 đọc là "F là type bất kỳ impl trait Fn với một tham số i32 và trả về i32". Bound này cho phép caller truyền bất cứ closure (hay function pointer) nào có signature khớp — và quan trọng nhất là caller không cần biết tên type của closure: compiler suy luận từ argument tại call site. Khi cần modify capture, đổi bound thành FnMut và thêm mut trước tham số (mut f: F); khi closure tiêu thụ capture sau một lần gọi, dùng FnOnce.
impl Trait Cú Pháp
Khi function chỉ có một (hay vài) tham số closure và không cần đặt tên type, Rust cho viết gọn bằng impl Trait ở vị trí tham số. Cú pháp này là syntactic sugar: compiler tự sinh ra generic ẩn ở dưới — kết quả runtime giống hệt phiên bản dùng <F: Fn(...)>.
// Tương đương `fn apply<F: Fn(i32) -> i32>(f: F) -> i32`
fn apply(f: impl Fn(i32) -> i32) -> i32 {
f(10)
}
fn main() {
let triple = |x| x * 3;
println!("{}", apply(triple)); // 30
}
Ưu điểm: ngắn, đọc tự nhiên hơn ở signature đơn giản. Hạn chế: không gọi tên type được nên không thể dùng turbofish (apply::<MyClosure>), không tham chiếu F ở chỗ khác trong signature (chẳng hạn ràng buộc hai tham số cùng closure type — trường hợp này phải dùng generic tường minh). Quy ước cộng đồng: signature đơn giản dùng impl Trait, signature có nhiều bound hoặc cần đặt tên type thì dùng generic. Cả hai cách tạo ra cùng một binary, không có chênh lệch hiệu năng.
Static Dispatch & Monomorphization
Cả hai cú pháp ở mục 2 và 3 đều dùng static dispatch: với mỗi closure type khác nhau được truyền vào, compiler sinh ra một bản copy của apply chuyên biệt cho closure type đó — đúng cơ chế monomorphization đã học ở Nhóm 18. Khi main gọi apply(double) và apply(add_one), compiler thực ra sinh ra hai function khác nhau ở binary: một bản inline gọi double, một bản inline gọi add_one.
Hệ quả thực tế: tốc độ ngang với gọi function thường — không có virtual table, không có indirection, optimizer thường inline thẳng body closure vào caller. Đổi lại, binary size phình lên tỉ lệ với số closure type truyền vào: gọi apply với 50 closure khác nhau ở 50 chỗ thì compiler sinh ra 50 bản copy. Với code thông thường tỉ lệ đánh đổi này hoàn toàn chấp nhận được; chỉ khi binary size là vấn đề (embedded, WASM) hoặc bạn cần lưu nhiều closure khác type vào cùng container (Vec<_> chẳng hạn) thì mới cần xét dynamic dispatch qua Box<dyn Fn> — chủ đề Bài 199.
Pattern Common — Iterator map
Pattern phổ biến nhất mọi Rust dev đều dùng hằng ngày là iterator combinator. Method map, filter, fold, for_each... đều có signature dạng nhận một closure và trả về iterator mới (hoặc một giá trị tích luỹ). Cụ thể map trên trait Iterator:
fn main() {
let v = vec![1, 2, 3, 4, 5];
// .map(closure) trả Iterator mới; .collect() vật chất hoá thành Vec
let doubled: Vec<i32> = v.iter().map(|x| x * 2).collect();
println!("{doubled:?}"); // [2, 4, 6, 8, 10]
// Chain: filter giữ chẵn, map bình phương, sum gộp
let sum_sq_even: i32 = v
.iter()
.filter(|&&x| x % 2 == 0)
.map(|x| x * x)
.sum();
println!("sum sq even = {sum_sq_even}"); // 4 + 16 = 20
}
Signature thực tế của map trong standard library xấp xỉ fn map<B, F: FnMut(Self::Item) -> B>(self, f: F) -> Map<Self, F> — dùng bound FnMut để closure có quyền modify capture giữa các phần tử (như cập nhật counter), trả về kiểu Map ôm cả iterator gốc và closure. Vì lazy, nếu không gọi collect, sum, hay for_each cuối chain thì closure không bao giờ chạy. Đây chính là lý do iterator chain trong Rust nhanh ngang vòng for thủ công: compiler inline qua nhiều layer monomorphize, không tạo intermediate Vec.
Custom Higher-Order — retry
Tự viết higher-order function rất đơn giản khi đã quen bound trait. Ví dụ một pattern hay gặp: retry — gọi closure tới max lần, dừng ngay khi nhận Ok, trả lỗi cuối nếu hết quota.
fn retry<T, E, F>(mut f: F, max: u32) -> Result<T, E>
where
F: FnMut() -> Result<T, E>,
{
let mut last_err = None;
for attempt in 1..=max {
match f() {
Ok(v) => return Ok(v),
Err(e) => {
eprintln!("attempt {attempt}/{max} failed");
last_err = Some(e);
}
}
}
Err(last_err.expect("max phải >= 1"))
}
fn main() {
let mut counter = 0;
let result: Result<&str, &str> = retry(|| {
counter += 1;
if counter < 3 { Err("not yet") } else { Ok("done") }
}, 5);
println!("{result:?}"); // Ok("done")
}
Bound FnMut được chọn vì closure cần modify counter giữa các lần gọi. Phải khai báo mut f: F ở tham số mới gọi f() nhiều lần được (closure FnMut yêu cầu &mut self). Nếu chỉ định Fn sẽ giới hạn caller — closure không modify capture mới truyền được, mất tính linh hoạt vô lý. Quy tắc đặt bound: chọn trait lỏng nhất mà function vẫn dùng được, để không gò bó caller.
Multiple Closure Params — pipe
Khi function nhận nhiều closure cùng lúc, mỗi closure cần một type parameter riêng — vì mỗi closure literal là một concrete type khác nhau. Ví dụ pipe ghép hai closure liên tiếp tạo composition g(f(x)):
fn pipe<T, M, U, F, G>(input: T, f: F, g: G) -> U
where
F: Fn(T) -> M,
G: Fn(M) -> U,
{
g(f(input))
}
fn main() {
let inc = |x: i32| x + 1;
let to_str = |x: i32| format!("value={x}");
let result = pipe(10, inc, to_str);
println!("{result}"); // value=11
}
F và G là hai type parameter độc lập, mỗi cái có bound riêng trong mệnh đề where. Output của f (kiểu trung gian M) khớp với input của g, compiler tự kiểm tra qua type inference. Không thể viết gọn bằng impl Fn cho cả hai tham số vì như thế hai closure sẽ chia sẻ cùng một anonymous type — không khả thi nếu signature khác nhau. Pattern này hữu ích khi viết DSL kiểu pipeline hoặc middleware chain. Khi số tham số closure tăng lên, mệnh đề where giúp signature dễ đọc hơn nhiều so với để bound inline trong dấu <...>.
Return Closure Preview
Đối xứng với việc nhận closure là trả closure khỏi function. Cú pháp gọn nhất là -> impl Fn(...) -> ... ở return position — cũng là static dispatch, một concrete type duy nhất ẩn dưới.
// Factory: trả closure cộng `n` vào input
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn main() {
let add5 = make_adder(5);
let add10 = make_adder(10);
println!("{}", add5(3)); // 8
println!("{}", add10(3)); // 13
}
Từ khoá move trước |x| bắt buộc — vì closure phải mang theo ownership của n ra khỏi scope của make_adder, không thể giữ reference đến biến local đã chết. Nếu cần trả nhiều type closure khác nhau từ cùng function (ví dụ tuỳ điều kiện rẽ nhánh return closure khác signature internal), phải dùng Box<dyn Fn(...) -> ...> — dynamic dispatch, sẽ học ở Bài 199. So sánh kỹ impl Fn vs Box<dyn Fn> ở return position là chủ đề Bài 200.
Tổng Kết
- Mỗi closure có concrete type ẩn danh — function muốn nhận closure phải khai báo qua generic với trait bound
Fn,FnMut, hoặcFnOnce. - Cú pháp
fn apply<F: Fn(i32) -> i32>(f: F)vàfn apply(f: impl Fn(i32) -> i32)ngữ nghĩa tương đương —impl Traitchỉ là syntactic sugar gọn hơn. - Cả hai dùng static dispatch + monomorphization: nhanh ngang gọi function thường, nhưng binary phình theo số closure type khác nhau.
- Pattern phổ biến nhất là iterator combinator (
map,filter,fold) — ănFnMut, lazy, không tạo intermediate buffer. - Tự viết higher-order: chọn bound lỏng nhất đủ dùng (
Fn<FnMut<FnOnce) để không gò bó caller.retrydùngFnMutvì closure cần modify state giữa các attempt. - Nhiều closure tham số → nhiều type parameter độc lập (
F,G...), thường viết qua mệnh đềwherecho dễ đọc. - Return closure bằng
-> impl Fn(...)(static, cầnmove) — đào sâu ở Bài 200; Bài 199 sẽ giới thiệuBox<dyn Fn>cho dynamic dispatch.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Viết
fn twice<F>(x: i32, f: F) -> i32 where F: Fn(i32) -> i32trảf(f(x)). Cùng signature đó, viết lại bằngimpl Fntrên tham số. - Function
fn run<F: Fn()>(f: F)được gọi với closurelet s = String::from("hi"); || drop(s). Compile có pass không? Nếu fail, sửa bound thế nào? - Vì sao
maptrên iterator dùng boundFnMutchứ không phảiFn? - Viết signature một function
fn compose3nhận ba closuref,g,hvà trả về kết quảh(g(f(x)))trên inputi32. - Hàm
make_adder(n: i32) -> impl Fn(i32) -> i32 { |x| x + n }bỏmoveđi sẽ báo lỗi gì? Vì sao bắt buộcmove?
Đáp án
- Generic:
fn twice<F>(x: i32, f: F) -> i32 where F: Fn(i32) -> i32 { f(f(x)) }. Bảnimpl Trait:fn twice(x: i32, f: impl Fn(i32) -> i32) -> i32 { f(f(x)) }. Hai cách sinh code tương đương sau monomorphization. - Fail. Closure
|| drop(s)consumesnên làFnOnce, không implFn. Sửa:fn run<F: FnOnce()>(f: F)— gọi closure được tối đa một lần. - Vì closure truyền vào
mapcó thể cần modify capture giữa các phần tử (đếm, tích luỹ debug log...).FnMutlà bound lỏng hơnFnnên chấp nhận cả closure read-only lẫn closure modify — không gò bó caller. fn compose3<F, G, H>(x: i32, f: F, g: G, h: H) -> i32 where F: Fn(i32) -> i32, G: Fn(i32) -> i32, H: Fn(i32) -> i32 { h(g(f(x))) }. Phải dùng generic vì ba closure khác concrete type.- Lỗi "closure may outlive the current function, but it borrows
n". Closure mặc định capture&n, mànchết khimake_adderreturn → reference dangling.moveép closure lấy ownershipn, sống cùng closure được trả ra.
Bài Tiếp Theo
Bài 198: move Keyword — Force Ownership — đào sâu từ khoá move đã thoáng qua ở mục 8. Học vì sao move bắt buộc khi spawn thread (thread::spawn) hay tokio task — closure cần 'static nên không được giữ reference đến local var. Bao gồm cách move ép mọi biến đi ownership, kèm pitfall move trong loop và cách fix bằng clone hoặc Arc.
