Danh sách bài viết

Bài 64: Ownership Với Function Call

Bài 64 của series Rust Cơ Bản — truyền tham số vào function là một context cực kỳ quan trọng của ownership. Khi gọi take(s) với s: String, parameter của function trở thành owner mới — caller mất quyền dùng s sau lời gọi, giống hệt như viết let inner = s;. Bài phân tích kỹ hành vi này, demo lỗi compile E0382: borrow of moved value, đối chiếu với behavior của Copy types (i32 không bị move), và liệt kê 3 cách fix khi cần dùng lại biến sau call: pass by reference (idiom), clone, restructure pipeline. Cuối bài phân biệt mut param (chỉ mutate cục bộ vì đã consume) với &mut (mutate binding của caller), và đưa ra rule of thumb chọn signature function.

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

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu rõ truyền tham số vào function chính là một phép gán — parameter là binding mới, áp dụng đầy đủ rule move/copy của ownership.
  • Đọc và giải thích được error E0382: borrow of moved value khi cố dùng lại biến sau khi pass vào function consume.
  • Phân biệt rõ behavior của Move types (String) và Copy types (i32) khi pass vào function — vì sao primitive ergonomic hơn.
  • Nắm 3 cách fix chính: pass by reference (idiom khuyến nghị), clone (đắt nhưng đơn giản), restructure pipeline (idiom của workflow consume-then-produce).
  • Phân biệt fn f(mut s: String) (mut param — mutate bản đã consume cục bộ) với fn f(s: &mut String) (mutable reference — mutate binding của caller). Đây là 2 thứ khác nhau hoàn toàn dù trông giống nhau.
  • Biết rule of thumb chọn signature: prefer reference trừ khi function thực sự cần consume value rồi return cái khác.

Bài này là cầu nối giữa Group 9 (Ownership) và Group 10 (References & Borrowing). Hiểu được hành vi function call sẽ giúp bạn đọc 80% signature trong Rust ecosystem mà không bối rối.

2

Pass By Value = Move (Mặc Định)

Trong Rust, signature mặc định của parameter là by value. Khi bạn viết fn take(s: String), từ góc nhìn ownership, parameter s chính là một binding mới sẽ nhận ownership từ argument bên gọi.

fn take(s: String) {
    println!("inside take: {s}");
    // s drop khi function return
}

fn main() {
    let s = String::from("hi");
    take(s);
    // Sau đây caller không còn dùng được s.
}

Cơ chế ở đây hoàn toàn giống một phép gán let inner = s; — chỉ khác là binding "inner" nằm trong scope function. Vì String không impl Copy nên đây là phép MOVE: stack header (ptr, len, cap) của s chuyển sang slot của parameter; binding smain bị compiler đánh dấu là invalid.

Khi function take return, parameter s ra khỏi scope của function — Rust gọi Drop::drop tự động, heap buffer "hi" được free ngay tại đó. Nói cách khác: gọi function consume một String giống như chuyển trách nhiệm dọn dẹp sang function đó.

Mental model: thay mỗi function call bằng inline let-binding để hình dung. take(s) tương đương { let s = s; /* body của take */ }. Khi nghĩ kiểu này, mọi rule ownership ở Bài 60 áp dụng nguyên xi — không có magic riêng cho function call.

3

Demo "value moved" Khi Pass

Đây là code minh hoạ — sao chép vào cargo new và chạy thử:

fn take(s: String) {
    println!("inside take: {s}");
}

fn main() {
    let s = String::from("hi");
    take(s);
    println!("{s}"); // compile error E0382
}

Compiler báo:

error[E0382]: borrow of moved value: `s`
 --> src/main.rs:8:15
  |
6 |     let s = String::from("hi");
  |         - move occurs because `s` has type `String`,
  |           which does not implement the `Copy` trait
7 |     take(s);
  |          - value moved here
8 |     println!("{s}");
  |               ^^^ value borrowed here after move

Đọc kỹ message: dòng take(s) được chỉ ra là "value moved here". Compiler hiểu rõ rằng việc pass argument là phép move — không cần bạn viết let _ = s; tường minh. Pattern lỗi này xuất hiện ở mọi nơi có function consume Move types: Vec, String, Box, HashMap, custom struct không derive Copy.

Để fix có 3 hướng tiếp cận (sẽ trình bày ở các mục sau): pass &s (borrow), pass s.clone() (sao chép), hoặc đơn giản hơn — không dùng lại s sau call. Trong nhiều trường hợp dev gặp lỗi này vì code có một println! debug thừa cuối main — bỏ luôn là xong.

4

Copy Type Pass Không Move

Đối lập hoàn toàn với String, các Copy types khi pass vào function được bitwise copy vào parameter — caller vẫn giữ binding gốc dùng bình thường:

fn double(x: i32) -> i32 {
    x * 2
}

fn main() {
    let n: i32 = 5;
    let m = double(n);   // n được COPY (4 byte) vào param x
    println!("n = {n}, m = {m}"); // OK — n vẫn dùng được
}

Không có lỗi move ở đây vì i32 impl trait Copy. Phép pass argument về bản chất vẫn là "tạo binding mới = argument" — nhưng đối với Copy type, "binding mới = nguồn" là một phép copy bit-by-bit, không invalidate nguồn. Cả i32, u64, f64, bool, char, các tuple chứa toàn Copy như (i32, bool), fixed-size array [i32; 4]... đều có hành vi này.

Đây là lý do code dùng primitive nhìn ergonomic hơn nhiều so với code dùng String: bạn có thể viết let a = sum(x, y) + sum(x, z); mà không lo "x đã bị move chưa". Với String, phép tương đương sẽ báo move ngay từ lần gọi thứ hai.

Câu hỏi thường gặp: vì sao Rust không tự copy mọi thứ cho tiện? Vì với heap buffer (như String), một phép copy ngầm sẽ tốn alloc + memcpy buffer — đắt và tiềm ẩn 2 owner cùng heap (double-free). Rust opt-in Copy chỉ cho các type rẻ và an toàn để copy (chủ yếu là dữ liệu trên stack, không sở hữu heap). Chi tiết về Copy trait đã ở Bài 62.

5

Fix 1 — Pass By Reference

Đây là cách fix idiom nhất và là cách bạn sẽ dùng cho 90% function chỉ cần "đọc" dữ liệu — function nhận reference thay vì value:

fn print(s: &String) {
    println!("inside print: {s}");
    // s là &String — borrow, không own buffer
}

fn main() {
    let s = String::from("hi");
    print(&s);            // pass reference, KHÔNG move
    println!("after: {s}"); // s vẫn là owner, vẫn dùng được
}

Cơ chế: &s tạo một reference trỏ tới binding s — không chuyển ownership. Parameter s: &String chỉ "mượn" để đọc; khi function return, reference ra khỏi scope và biến mất (không gọi drop trên heap buffer vì reference không own). Caller giữ nguyên ownership từ đầu tới cuối.

Đây là idiom Rust khuyến nghị mỗi khi bạn không có lý do rõ ràng để consume value. Trong thực tế, idiom còn được tinh chỉnh thêm một bước: nhận &str thay vì &String để function linh hoạt hơn (chấp nhận cả string literal lẫn String). Chi tiết về reference, deref coercion, và borrow rules sẽ ở Group 10: References & Borrowing.

Tip: khi đọc Rust code production, nếu bạn thấy function có signature fn foo(s: String) — nghĩa là tác giả cố ý muốn consume s (vd: insert vào struct field, đẩy vào Vec, gửi qua channel). Còn fn foo(s: &String) hoặc fn foo(s: &str) chỉ định "tôi sẽ đọc/inspect thôi, không lấy đi đâu".

6

Fix 2 — Clone Trước Khi Pass

Cách thứ hai: vẫn giữ signature consume, nhưng tạo bản sao trước khi pass — caller giữ bản gốc, function nhận bản clone:

fn take(s: String) {
    println!("inside take: {s}");
}

fn main() {
    let s = String::from("hi");
    take(s.clone()); // tạo bản sao heap mới, pass bản đó vào
    println!("after: {s}"); // s gốc còn nguyên
}

Cách này đơn giản nhất về mặt code: thêm đúng .clone() tại call site, không phải đổi signature function. Nhược điểm: tốn 1 lần alloc + memcpy toàn bộ heap buffer. Với buffer 8 byte ("hi") không đáng kể; với buffer vài MB hoặc lặp lại trong hot loop thì là vấn đề perf rõ rệt.

Khi nào nên dùng clone:

  • Buffer nhỏ, không lặp nhiều — vd cấu hình ban đầu, key string ngắn.
  • Bạn thực sự cần 2 bản dữ liệu độc lập — vd 1 bản để log, 1 bản để gửi đi mạng và bị consume.
  • Prototype nhanh trước khi tối ưu — thường refactor sau bằng Arc (shared ownership) hoặc reference khi đã hiểu data flow.

Khi nào KHÔNG nên clone bừa: hot loop xử lý nhiều element, struct lớn (vd Vec<User> với hàng nghìn record), context cần latency thấp. Trade-off perf vs ergonomics: clone nhanh viết, nhưng performance cost xuất hiện ở runtime — borrow phải nghĩ kỹ hơn, nhưng zero-cost. Triết lý Rust khuyến khích bạn dành công sức cho borrow để code chạy nhanh, thay vì clone cho dễ rồi tốn CPU.

7

Fix 3 — Restructure Pipeline

Cách thứ ba — và thường là cách elegant nhất khi áp dụng được: thay đổi cách suy nghĩ. Thay vì cố giữ lại biến gốc sau khi function consume, tổ chức code thành pipeline: function consume rồi trả về kết quả, kết quả mới đi tiếp:

fn transform(s: String) -> String {
    s.to_uppercase()
}

fn print(s: &String) {
    println!("{s}");
}

fn main() {
    let s = String::from("hello");
    let result = transform(s);   // s bị consume, result là String mới
    print(&result);              // dùng result đi tiếp
    // s không còn — và bạn không cần s nữa, OK.
}

Khi nhìn lại nhiều bug "value moved" trong quá trình học Rust, dev thường nhận ra: biến gốc thực ra không cần dùng lại — chỉ là thói quen JS/Python để lại s ở scope vì "lỡ cần thì sao". Rust ép ta tỉnh táo về vấn đề này: chỉ giữ biến khi thực sự cần.

Pipeline thường gặp trong Rust:

let raw = read_input();
let parsed = parse(raw);          // raw bị consume
let normalized = normalize(parsed); // parsed bị consume
let result = compute(normalized);   // normalized bị consume
println!("{result}");

Mỗi bước "tịch thu" giá trị bước trước, tạo giá trị mới. Đây là idiom phổ biến của iterator adapter, builder pattern, và monadic chain (Result::map, Option::and_then). Khi bạn quen suy nghĩ pipeline, các function consume trở nên rất tự nhiên — không cần fight với borrow checker.

8

Function Param mut — Không Phải &mut

Đây là điểm rất hay gây nhầm lẫn cho người mới: trong signature, bạn có thể viết:

fn take(mut s: String) {
    s.push_str(" world");
    println!("inside: {s}");
}

Từ khoá mut ở đây chỉ làm parameter binding trở nên mutable cục bộ — bạn được phép modify nó bên trong function. Nhưng vì parameter đã consume argument (move toàn bộ), việc modify này KHÔNG ảnh hưởng caller: caller đã mất binding gốc rồi, có gì đâu mà ảnh hưởng.

So sánh trực tiếp với &mut String:

fn consume_mut(mut s: String) {
    s.push_str(" world");
    println!("inside: {s}");
}

fn borrow_mut(s: &mut String) {
    s.push_str(" world");
    println!("inside: {s}");
}

fn main() {
    let mut owned = String::from("hello");

    // consume_mut(owned);          // MOVE — owned không còn dùng được
    // println!("after: {owned}");  // compile error E0382

    borrow_mut(&mut owned);        // BORROW mutable — owned vẫn ok
    println!("after: {owned}");    // in "hello world"
}

Hai signature trông gần giống nhau, nhưng semantics khác hẳn:

  • fn consume_mut(mut s: String) — function chiếm hữu string, có thể đổi nó tuỳ ý nhưng caller không thấy gì (caller đã mất binding). Mutation chỉ có giá trị nếu cuối function trả về s hoặc dùng nó tạo gì khác.
  • fn borrow_mut(s: &mut String) — function mượn string với quyền chỉnh sửa, caller giữ ownership và thấy thay đổi sau khi function return. Caller phải khai báo let mut owned và truyền &mut owned.

Rule of thumb: muốn mutate visible cho callerluôn dùng &mut. Còn mut trên parameter chỉ là tiện nghi cú pháp khi bạn muốn modify biến cục bộ trong function (tương tự let mut x trong body — không ai thấy ngoài function). Chi tiết &mut, borrow rules, và tại sao chỉ 1 mutable reference tại một thời điểm sẽ ở Bài 69: &mut — Mutable Reference.

9

Pattern Phổ Biến: Caller Quyết Định Ownership

Khi viết function của riêng mình, bạn thường đối mặt với câu hỏi: nên nhận by-value, by-reference, hay by-mutable-reference? Trong ecosystem Rust, có pattern rất rõ ràng — function signature đã nói lên ý định:

// (1) Function chỉ ĐỌC — nhận immutable reference
fn process_by_ref(s: &str) {
    println!("reading: {s}");
}

// (2) Function ĐỌC + GHI vào chính buffer của caller — nhận &mut
fn process_by_mut(s: &mut String) {
    s.push_str("!");
}

// (3) Function CONSUME để đẩy đi nơi khác (vd insert vào Vec) — nhận by value
fn process_by_value(s: String) -> Vec<String> {
    vec![s] // s được đẩy vào Vec, ownership chuyển sang Vec
}

Rule of thumb khi thiết kế signature:

  • Mặc định prefer reference (&str, &[T], &T). Đây là cách tổng quát nhất — caller có thể gọi nhiều lần với cùng một biến, không lo move.
  • Dùng &mut khi function cần modify in-place buffer của caller — vd buf.sort(), vec.push(x).
  • Dùng by value CHỈ KHI function thực sự cần consume — vd insert vào collection, gửi qua channel, transform thành type khác và trả về, hoặc store vào struct field.

Áp dụng vào thực tế: khi đọc tài liệu của std::collections::HashMap, bạn sẽ thấy fn insert(&mut self, k: K, v: V) -> Option<V>kv by value (HashMap cần consume để lưu); còn fn get(&self, k: &Q) -> Option<&V>k by reference (chỉ tra cứu). Mỗi signature là một cam kết với caller về việc function sẽ làm gì với ownership.

Khi caller đọc signature, họ biết ngay: nếu thấy String không có & thì "à, gọi xong là tôi mất biến này"; còn thấy &String thì "yên tâm, gọi xong vẫn dùng được". Convention này làm Rust code dễ predict — bạn không cần đọc body function để biết nó sẽ làm gì với argument.

10

Tổng Kết

  • Truyền tham số by value vào function = một phép gán: parameter là binding mới nhận ownership. Áp dụng đầy đủ rule move/copy của ownership.
  • Với Move types (String, Vec, Box...): pass là MOVE → caller mất quyền dùng → lỗi compile E0382: borrow of moved value khi cố dùng lại.
  • Với Copy types (i32, bool, char...): pass là bitwise COPY → caller vẫn dùng được. Đây là lý do code dùng primitive ergonomic hơn dùng String.
  • 3 cách fix khi cần dùng lại biến sau call: (1) pass by reference &s — idiom khuyến nghị, zero-cost; (2) clone s.clone() — đơn giản nhưng tốn alloc + memcpy; (3) restructure pipeline — function consume rồi trả về, dùng kết quả mới.
  • Phân biệt fn f(mut s: String) vs fn f(s: &mut String): cái đầu chiếm hữu rồi mutate cục bộ (caller không thấy gì); cái sau mượn mutable, caller giữ ownership và thấy thay đổi.
  • Rule of thumb signature: prefer reference (&str, &[T]) cho input chỉ đọc; &mut khi cần mutate buffer của caller; by value chỉ khi function thực sự consume (insert collection, transform return, gửi channel, store struct).
  • Signature function là cam kết với caller về việc làm gì với ownership — đọc signature là đoán được behavior, không cần đọc body.
11

Bài Tập Củng Cố

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

  1. Cho code: let v = vec![1,2,3]; fn show(x: Vec<i32>) { println!("{x:?}"); } show(v); println!("{v:?}");. Compile được không? Vì sao? Liệt kê 2 cách fix.
  2. Vì sao fn double(x: i32) -> i32 { x * 2 } let n = 5; double(n); double(n); không bị báo move, nhưng đổi i32 thành String thì lần gọi thứ hai báo lỗi?
  3. Bạn viết fn append(mut s: String) { s.push_str("x"); } và gọi let mut owned = String::from("hi"); append(owned); println!("{owned}");. Output là gì? Có lỗi không? Nếu muốn thấy "hix" ở caller, phải sửa thế nào?
  4. Trong ecosystem Rust, signature fn validate(input: &str) -> bool phổ biến hơn fn validate(input: String) -> bool. Vì sao?
  5. Khi nào nên dùng .clone() trước khi pass vào function consume, khi nào KHÔNG nên? Cho ví dụ mỗi trường hợp.
Đáp án
  1. Không compile. Lỗi borrow of moved value: vVec<i32> không impl Copy nên show(v) là MOVE, sau đó println!("{v:?}") dùng v đã invalidate. Fix: (a) đổi signature thành fn show(x: &Vec<i32>) hoặc tốt hơn fn show(x: &[i32]), gọi show(&v); (b) show(v.clone()) để pass bản sao, giữ v gốc.
  2. i32 impl Copy — mỗi lần pass là một phép copy 4 byte vào parameter, binding n ở caller không bị invalidate. String không impl Copy (vì sở hữu heap buffer), mỗi lần pass là MOVE, lần thứ hai dùng biến đã moved out → lỗi.
  3. Output: "hi" — không có "x". Không compile thực ra: append(owned) consume owned, dòng println!("{owned}") sau đó báo lỗi E0382. Việc mut s trong append chỉ mutate bản đã consume cục bộ — caller mất binding rồi. Để fix và thấy "hix": đổi signature thành fn append(s: &mut String) { s.push_str("x"); } rồi gọi append(&mut owned);.
  4. &str tổng quát hơn nhiều: caller có thể truyền cả string literal ("abc"), String qua deref coercion (&s), slice của String (&s[0..5]). Còn String bắt buộc caller phải có String owned và sẵn sàng "mất" nó — gây phiền cho mọi caller chỉ cần validate. Idiom Rust: nhận &str cho input đọc-only, return String nếu cần produce owned data.
  5. NÊN clone: buffer nhỏ (config string, key map), prototype nhanh chưa cần tối ưu, cần thực sự 2 bản dữ liệu độc lập (vd 1 để log, 1 để gửi qua mạng). KHÔNG nên: hot loop xử lý nhiều record (clone trong loop = O(n) alloc), buffer lớn (vài MB), khi reference đủ dùng. Ví dụ NÊN: config_loader.load(path.clone()) chạy 1 lần lúc startup. Ví dụ KHÔNG: for user in users { send_email(user.email.clone()) } nên đổi thành send_email(&user.email).
12

Bài Tiếp Theo

Bài 65: Trả Ownership Từ Function — chiều ngược lại của bài này: function return value cũng là một phép MOVE — ownership chuyển từ function ra caller. Bài 65 phân tích pattern verbose "take-and-return-back" thường gặp ở Rust beginner, lý do vì sao reference (Group 10) là giải pháp idiom hơn, và cách dùng tuple return cho multi-value khi muốn trả về nhiều thứ cùng lúc.