Danh sách bài viết

Bài 198: move Keyword — Force Ownership

Bài 198 của series Rust Cơ Bản — mổ xẻ kỹ từ khoá move đã xuất hiện thoáng qua ở Bài 195. Đây là từ khoá đứng trước dấu | mở của closure, ép compiler bỏ rule "least restrictive capture" và lấy ownership của mọi biến outer được dùng trong body — kể cả khi body chỉ đọc và một &T đã đủ. Bài làm rõ hai khái niệm rất dễ lẫn: capture mode (cách closure giữ biến: by ref / by mut ref / by move) khác hẳn closure trait (cách closure được gọi: Fn / FnMut / FnOnce) — move chỉ tác động đến cái thứ nhất, còn cái thứ hai vẫn do body quyết định. Sau đó đi qua ba use case mà move gần như bắt buộc: spawn OS thread với std::thread::spawn, spawn async task với tokio::spawn(async move { ... }), và trả closure từ function (preview Bài 199). Cuối bài là lỗi E0373 "closure may outlive the current function, but it borrows X, which is owned by the current function" — bug kinh điển khi quên move, kèm cách compiler trực tiếp gợi ý fix.

09/06/2026
9 phút đọc
1 lượt xem
1

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 qua std::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ĩa move với async future.
  • Đọc và fix được lỗi E0373 "closure may outlive the current function".
  • Hiểu vì sao thêm move không tự động biến closure thành FnOnce — 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.

2

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 movethay 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.

3

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ải move.
  • 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.

4

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, datalabel 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.

5

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{.

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ả.

6

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: nname 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).

7

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.

8

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.

9

Tổng Kết

  • move đứng trước |...| của closure (hoặc giữa async{ với async block) — ép mọi biến capture đi bằng ownership.
  • Cần move khi 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.
  • move chỉ thay capture mode, không quyết định trait — body mới quyết định Fn / 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.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Đoạn code let n = 5; let f = move || n + 1;. Sau khi tạo f, dòng println!("{n}") còn chạy được không? Vì sao?
  2. Closure let s = String::from("x"); let g = move || println!("{s}");. Closure g impl trait gì trong ba trait Fn / FnMut / FnOnce? Gọi g() hai lần có lỗi không?
  3. thread::spawn(|| { let v = vec![1,2,3]; println!("{v:?}"); }) có cần move không? Vì sao?
  4. 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?
  5. 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
  1. Vẫn chạy được. i32Copy nên "move" thực chất là copy bit của 5 vào closure — biến n ở scope cha vẫn tồn tại nguyên vẹn. Chỉ với non-Copy type (như String, Vec) thì biến gốc mới bị invalidate.
  2. impl Fn (và cả FnMut, FnOnce do hierarchy). Body chỉ đọc s qua println!, 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.
  3. 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ả 'static sẵn. move ở đây là noise, không sai nhưng không cần.
  4. Không compile. async { use_conn(&conn).await } borrow &conn từ handler, mà future được gửi qua tokio::spawn phải 'static. Fix: async move { use_conn(&conn).await } — chữ move giữa async{ để future own conn.
  5. Không compile. Closure || println!("{s}") capture &s, mà s là local của make — return ra ngoài là dangling. Fix: thêm move trước || để closure own s: move || println!("{s}").
11

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ế.