Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu cú pháp
move |args| body— từ khoámoveđứng ngay trước dấu|mở, ép mọi biến capture đi bằng ownership. - Phân biệt rõ capture mode (do
moveđiều khiển) với closure trait (do body điều khiển) — hai khái niệm độc lập, không phải đồng nhất. - Biết khi nào compiler buộc phải có
move: gửi quastd::thread::spawn,tokio::spawn, trả closure ra khỏi function — đều cần closure'static. - Biết cú pháp
async move { ... }cho async block trong tokio — tương đương ý nghĩamovevới async future. - Đọc và fix được lỗi
E0373"closure may outlive the current function". - Hiểu vì sao thêm
movekhông tự động biến closure thànhFnOnce— body mới quyết định trait.
Bài kế thừa Bài 195 (capture mode) và Bài 196 (Fn / FnMut / FnOnce). Bài 199 sẽ tiếp tục với cách trả closure từ function dùng Box<dyn Fn> — use case quan trọng thứ ba của move.
move |x| ... Cú Pháp
Cú pháp tối giản: ghi từ khoá move ngay trước dấu | mở của closure. Phần còn lại — danh sách tham số, return type, body — không thay đổi gì.
// Không move: capture mode do compiler suy luận
let f = |x: i32| x + n;
// Có move: ép capture toàn bộ outer var bằng ownership
let g = move |x: i32| x + n;
// move với block body
let h = move |x: i32| {
println!("n = {n}");
x + n
};
Hiệu ứng duy nhất của move là thay capture mode. Khi compiler thấy move, mọi biến outer được closure body tham chiếu — dù chỉ đọc, dù modify, dù consume — đều bị move vào struct ẩn của closure. Sau khi closure được tạo, các biến đó không còn dùng được ở scope cha (trừ khi là kiểu Copy như i32, bool — thì "move" thực chất là copy bit, biến gốc vẫn còn).
Không có cú pháp "move một số biến" — move all-or-nothing. Để chọn lọc, dùng pattern bind-before-closure: tạo binding mới (qua clone, Arc::clone) rồi move binding đó thay vì biến gốc.
Tại Sao Cần move
Mặc định, compiler chọn capture mode tối thiểu (least restrictive): chỉ đọc thì &T, modify thì &mut T, consume thì move. Quy tắc này tốt cho 90% trường hợp vì giữ tối đa quyền cho code xung quanh. Nhưng có hai tình huống mode tối thiểu không đủ:
- Outer var có lifetime ngắn hơn closure. Khi closure được truyền đi nơi khác (thread khác, executor khác, hoặc trả ra ngoài function), nó cần sống lâu hơn scope hiện tại. Một capture
&Tđến local var không sống lâu vậy — reference sẽ dangling khi local hết scope. Compiler báo lỗi, ép phảimove. - Bound đòi
'static. Các API nhưthread::spawn<F: FnOnce() + Send + 'static>yêu cầu closure thoả mãn'static— nghĩa là không giữ reference nào có lifetime ngắn hơn toàn bộ chương trình. Cách duy nhất để thoả mãn là không capture reference, tức là capture by move.
Khi compiler tự suy luận sai theo nhu cầu thực tế (chọn &T trong khi bạn cần ownership), move là "công tắc cứng" để ép. Đây không phải tối ưu performance — đây là vấn đề tính đúng đắn lifetime.
Use Case: Spawn Thread
Use case kinh điển và phổ biến nhất: gửi data sang OS thread mới qua std::thread::spawn. Signature yêu cầu closure FnOnce() + Send + 'static — bound 'static chính là lý do bắt buộc move.
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
let label = String::from("worker-A");
let handle = thread::spawn(move || {
// data và label đã move sang thread mới
let sum: i32 = data.iter().sum();
println!("{label}: sum = {sum}");
});
// println!("{data:?}"); // ERROR: data đã moved
handle.join().unwrap();
}
Sau khi move, data và label trở thành property của closure, đi cùng thread mới. Thread con sống có thể lâu hơn main scope (nếu không join), nhưng data nằm trong closure thì sống cùng closure — không có chuyện dangling reference qua thread boundary. Đây chính là cách Rust đạt "fearless concurrency": compiler từ chối compile mọi đoạn code có thể đua data race do lifetime sai.
Use Case: Async Task
Async task spawn qua tokio::spawn có hạn chế tương tự: future phải Send + 'static để executor có thể di chuyển nó giữa các thread trong runtime multi-thread. Async block (không phải closure) có cú pháp riêng — thêm move đứng giữa async và {.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let url = String::from("https://blogcode.vn");
let id = 42u32;
let handle = tokio::spawn(async move {
// url và id đã move vào future
sleep(Duration::from_millis(100)).await;
println!("[{id}] fetched {url}");
});
handle.await.unwrap();
}
Cú pháp async move { ... } tạo một future capture toàn bộ biến outer bằng ownership. Khi tokio executor di chuyển future này sang worker thread khác để poll tiếp, dữ liệu đi cùng — không phụ thuộc local frame của main. Quên move trong tokio::spawn sẽ ra lỗi gần giống thread::spawn: "borrowed value does not live long enough" hoặc bound 'static không thoả.
Use Case: Return Closure
Closure trả ra khỏi function phải own mọi thứ nó capture — không được giữ reference đến local của hàm cha vì local sẽ bị drop ngay khi hàm cha return, kéo theo dangling.
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n // n bị copy (i32 là Copy) vào closure
}
fn make_greeter(name: String) -> impl Fn() {
move || println!("Xin chào, {name}!")
// Không có move: n và name là local của make_*,
// closure giữ &name thì name drop là dangling.
}
fn main() {
let add5 = make_adder(5);
println!("{}", add5(10)); // 15
let greet = make_greeter(String::from("Rust"));
greet();
}
Cả hai hàm trên cần move: n và name là parameter local của make_adder / make_greeter, sống đến hết function rồi drop. Closure trả ra phải mang theo bản sao/ownership thì mới còn hợp lệ ở caller. Bài 199 sẽ đi sâu vào hai cách trả closure: impl Fn (static dispatch, mỗi function chỉ trả về một concrete type) và Box<dyn Fn> (dynamic dispatch, có thể chọn closure khác nhau theo runtime).
move Vẫn Tuân Fn/FnMut/FnOnce Rules
Đây là điểm hay bị nhầm: thêm move không tự động biến closure thành FnOnce. move quyết định cách closure giữ biến (ownership), còn Fn / FnMut / FnOnce quyết định body làm gì với biến đã giữ.
fn main() {
let s = String::from("Rust");
// move + body chỉ đọc → impl Fn (gọi nhiều lần OK)
let f = move || println!("{s}");
f();
f();
f(); // OK: s đã move vào closure, chỉ đọc nên gọi mấy lần cũng được
// move + body consume → impl FnOnce (chỉ gọi 1 lần)
let v = vec![1, 2, 3];
let consume = move || {
let _taken = v; // move v ra khỏi closure
println!("consumed");
};
consume();
// consume(); // ERROR: closure cannot be called more than once
}
Hai closure trên đều có move, nhưng trait khác nhau hoàn toàn: f đọc s qua &s bên trong (string vẫn nằm trong struct closure, body chỉ borrow nội bộ), nên impl Fn. consume chuyển v ra ngoài qua let _taken = v, nên impl FnOnce. Rule chung: nhìn vào body để biết trait, nhìn vào move để biết capture mode.
Common Bug Khi Forget move
Lỗi quên move phổ biến nhất là E0373. Compiler in ra rất rõ ràng và còn gợi ý fix luôn — đọc kỹ message là biết phải làm gì.
use std::thread;
fn main() {
let msg = String::from("hello");
// SAI: thiếu move
thread::spawn(|| println!("{msg}"));
// ^^ ^^^ `msg` is borrowed here
// |
// may outlive borrowed value `msg`
}
// error[E0373]: closure may outlive the current function,
// but it borrows `msg`, which is owned by the current function
// help: to force the closure to take ownership of `msg`
// (and any other referenced variables), use the `move` keyword
Fix bằng cách thêm đúng một từ move:
use std::thread;
fn main() {
let msg = String::from("hello");
// ĐÚNG
let handle = thread::spawn(move || println!("{msg}"));
handle.join().unwrap();
}
Cùng lỗi này áp dụng cho tokio::spawn, std::thread::Builder::spawn, các hàm callback nhận FnOnce + 'static. Khi gặp message "may outlive the current function" hoặc "borrowed value does not live long enough" kèm context closure, phản xạ đầu tiên là kiểm tra có thiếu move không. Nếu thêm move rồi vẫn lỗi (vì capture type không phải Send hoặc dùng lại biến gốc sau spawn), lúc đó mới đào sâu thêm.
Tổng Kết
moveđứng trước|...|của closure (hoặc giữaasyncvà{với async block) — ép mọi biến capture đi bằng ownership.- Cần
movekhi outer var có lifetime ngắn hơn closure, hoặc bound đòi'static. - Use case bắt buộc:
std::thread::spawn,tokio::spawn(async move { ... }), trả closure ra khỏi function. movechỉ thay capture mode, không quyết định trait — body mới quyết địnhFn/FnMut/FnOnce.- Lỗi quen thuộc
E0373"closure may outlive the current function" — gợi ý fix nằm ngay trong help message của compiler. - Kiểu
Copy(nhưi32,bool) bị "move" thực chất là copy — biến gốc vẫn dùng được ở scope cha.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Đoạn code
let n = 5; let f = move || n + 1;. Sau khi tạof, dòngprintln!("{n}")còn chạy được không? Vì sao? - Closure
let s = String::from("x"); let g = move || println!("{s}");. Closuregimpl trait gì trong ba traitFn/FnMut/FnOnce? Gọig()hai lần có lỗi không? thread::spawn(|| { let v = vec![1,2,3]; println!("{v:?}"); })có cầnmovekhông? Vì sao?- Trong
async fn handler() { let conn = String::from("db"); tokio::spawn(async { use_conn(&conn).await; }); }— đoạn này compile được không? Fix thế nào? - Function
fn make() -> impl Fn() { let s = String::from("hi"); || println!("{s}") }compile được không? Nếu không, thêm/sửa ở đâu?
Đáp án
- Vẫn chạy được.
i32làCopynên "move" thực chất là copy bit của 5 vào closure — biếnnở scope cha vẫn tồn tại nguyên vẹn. Chỉ với non-Copytype (nhưString,Vec) thì biến gốc mới bị invalidate. - impl
Fn(và cảFnMut,FnOncedo hierarchy). Body chỉ đọcsquaprintln!, không consume — gọi nhiều lần OK.moveở đây chỉ thay đổi capture mode (ownership thay vì&s), không ảnh hưởng số lần gọi. - Không cần.
vđược khai báo bên trong closure, không phải capture từ outer scope — closure không borrow gì từmain, đã thoả'staticsẵn.moveở đây là noise, không sai nhưng không cần. - Không compile.
async { use_conn(&conn).await }borrow&conntừhandler, mà future được gửi quatokio::spawnphải'static. Fix:async move { use_conn(&conn).await }— chữmovegiữaasyncvà{để future ownconn. - Không compile. Closure
|| println!("{s}")capture&s, màslà local củamake— return ra ngoài là dangling. Fix: thêmmovetrước||để closure owns:move || println!("{s}").
Bài Tiếp Theo
Bài 199: Trả Closure Từ Function — Box<dyn Fn> — đi sâu vào use case thứ ba của move: function trả closure ra ngoài. Mỗi closure có một concrete type ẩn do compiler sinh, không thể đặt tên thẳng trong return signature, nên cần một trong hai chiến lược: Box<dyn Fn(...) -> ...> (dynamic dispatch, đổi closure theo runtime) hoặc impl Fn(...) -> ... (static dispatch, mỗi function chỉ trả về đúng một concrete type). Bài sẽ so sánh trade-off heap allocation, vtable lookup, monomorphization, kèm ví dụ builder pattern và factory function thực tế.
