Mục lục
- Mục Tiêu Bài Học
- Pass By Value = Move (Mặc Định)
- Demo "value moved" Khi Pass
- Copy Type Pass Không Move
- Fix 1 — Pass By Reference
- Fix 2 — Clone Trước Khi Pass
- Fix 3 — Restructure Pipeline
- Function Param mut — Không Phải &mut
- Pattern Phổ Biến: Caller Quyết Định Ownership
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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 valuekhi 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ớifn 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.
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 s ở main 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.
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.
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.
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".
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.
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.
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ềshoặ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áolet mut ownedvà truyền&mut owned.
Rule of thumb: muốn mutate visible cho caller → luô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.
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
&mutkhi function cần modify in-place buffer của caller — vdbuf.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> — k và v 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.
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 compileE0382: borrow of moved valuekhi 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) clones.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)vsfn 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;&mutkhi 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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- 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. - Vì sao
fn double(x: i32) -> i32 { x * 2 } let n = 5; double(n); double(n);không bị báo move, nhưng đổii32thànhStringthì lần gọi thứ hai báo lỗi? - Bạn viết
fn append(mut s: String) { s.push_str("x"); }và gọilet 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? - Trong ecosystem Rust, signature
fn validate(input: &str) -> boolphổ biến hơnfn validate(input: String) -> bool. Vì sao? - 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
- Không compile. Lỗi
borrow of moved value: v—Vec<i32>không impl Copy nênshow(v)là MOVE, sau đóprintln!("{v:?}")dùngvđã invalidate. Fix: (a) đổi signature thànhfn show(x: &Vec<i32>)hoặc tốt hơnfn show(x: &[i32]), gọishow(&v); (b)show(v.clone())để pass bản sao, giữvgốc. - Vì
i32implCopy— mỗi lần pass là một phép copy 4 byte vào parameter, bindingnở caller không bị invalidate.Stringkhô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. - Output:
"hi"— không có"x". Không compile thực ra:append(owned)consumeowned, dòngprintln!("{owned}")sau đó báo lỗi E0382. Việcmut strongappendchỉ mutate bản đã consume cục bộ — caller mất binding rồi. Để fix và thấy "hix": đổi signature thànhfn append(s: &mut String) { s.push_str("x"); }rồi gọiappend(&mut owned);. - Vì
&strtổng quát hơn nhiều: caller có thể truyền cả string literal ("abc"),Stringqua deref coercion (&s), slice của String (&s[0..5]). CònStringbắ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&strcho input đọc-only, returnStringnếu cần produce owned data. - 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ànhsend_email(&user.email).
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.
