Iterator và closure trong Rust
1. Tổng quan
Closure và iterator là hai công cụ giúp bạn xử lý dữ liệu theo kiểu hàm trong Rust mà vẫn giữ được hiệu năng ngang vòng lặp viết tay. Bài này giải thích cách closure capture biến từ môi trường và ba trait Fn, FnMut, FnOnce. Sau đó bài đi qua trait Iterator, các adapter phổ biến và cách tự viết một iterator. Đọc xong, bạn sẽ viết được các pipeline như filter → map → collect một cách tự tin. Bạn cũng sẽ biết vì sao compiler đôi khi từ chối code của bạn.
Toàn bộ ví dụ dùng Rust 1.80+ (edition 2021), chỉ cần thư viện chuẩn.
2. Closure cơ bản
2.1. Cú pháp
Closure là một hàm ẩn danh có thể “nhìn thấy” biến ở scope bao quanh nó:
fn main() {
let discount = 10; // phần trăm
let apply_discount = |price: u32| price - price * discount / 100;
println!("{}", apply_discount(250));
println!("{}", apply_discount(1000));
}
225
900
Tham số nằm giữa hai dấu |...|, thân closure nằm ngay sau. Closure dùng biến discount dù biến này không được truyền vào. Một hàm fn thông thường không làm được điều đó.
Kiểu tham số và kiểu trả về thường được suy luận tự động. Bạn có thể viết |price| ... nếu compiler đủ thông tin. Tuy vậy, kiểu chỉ được suy luận một lần. Nếu lần đầu bạn gọi closure với u32, lần sau gọi với i64 sẽ lỗi biên dịch.
2.2. Ba cách capture biến
Closure capture biến theo cách “nhẹ nhất” mà thân closure cần. Thứ tự ưu tiên là borrow bất biến, rồi borrow khả biến, cuối cùng là lấy ownership.
fn main() {
let mut cart = vec!["bút"];
let print_cart = || println!("Giỏ: {:?}", cart); // borrow &cart
print_cart();
let mut add_item = |item| cart.push(item); // borrow &mut cart
add_item("vở");
add_item("thước");
println!("Cuối cùng: {:?}", cart);
}
Giỏ: ["bút"]
Cuối cùng: ["bút", "vở", "thước"]
Phân tích từng closure:
print_cartchỉ đọccart, nên nó giữ một&cart.add_itemgọipush, nên nó cần&mut cart. Biến closure cũng phải khai báomut, vì gọi nó sẽ thay đổi trạng thái bên trong.- Code biên dịch được nhờ non-lexical lifetimes.
print_cartkhông còn được dùng sau lần gọi đầu, nên borrow bất biến kết thúc trước khi borrow khả biến bắt đầu.
Khi cần closure sở hữu dữ liệu, bạn thêm từ khóa move. Trường hợp điển hình là chuyển closure sang thread khác:
use std::thread;
fn main() {
let customer = String::from("Lan");
let handle = thread::spawn(move || {
println!("Đang xử lý đơn hàng cho {customer}");
});
handle.join().unwrap();
}
Đang xử lý đơn hàng cho Lan
Không có move, compiler báo lỗi. Thread mới có thể sống lâu hơn main, nên một tham chiếu tới customer không an toàn. Với move, String được chuyển hẳn vào closure. Sau dòng đó, bạn không dùng customer trong main được nữa.
3. Ba trait Fn, FnMut và FnOnce
Mỗi closure tự động implement một hoặc nhiều trong ba trait sau. Trait nào được implement phụ thuộc vào việc closure làm gì với biến đã capture:
| Trait | Closure làm gì với biến capture | Gọi được bao nhiêu lần |
|---|---|---|
Fn |
Chỉ đọc | Nhiều lần |
FnMut |
Thay đổi | Nhiều lần |
FnOnce |
Tiêu thụ (move ra ngoài) | Một lần |
Ba trait này có quan hệ kế thừa: mọi Fn đều là FnMut, và mọi FnMut đều là FnOnce. Khi viết hàm nhận closure, bạn nên chọn trait lỏng nhất mà hàm cần. Như vậy người gọi có nhiều lựa chọn nhất.
fn run_twice<F: Fn()>(f: F) {
f();
f();
}
fn run_twice_mut<F: FnMut()>(mut f: F) {
f();
f();
}
fn run_once<F: FnOnce() -> String>(f: F) -> String {
f()
}
fn main() {
let name = String::from("Minh");
run_twice(|| println!("Xin chào {name}"));
let mut count = 0;
run_twice_mut(|| count += 1);
println!("count = {count}");
let receipt = run_once(move || name + " - đã thanh toán");
println!("{receipt}");
}
Xin chào Minh
Xin chào Minh
count = 2
Minh - đã thanh toán
Ba lời gọi tương ứng với ba trait:
- Closure đầu chỉ đọc
name, nên nó làFn. - Closure thứ hai tăng
count, nên nó làFnMut. Truyền nó vàorun_twicesẽ lỗi. - Closure cuối dùng
name + "...". Toán tử+trênStringlấy ownership củaname, nên closure chỉ gọi được một lần và chỉ implementFnOnce.
3.1. Trả về closure từ hàm
Mỗi closure có một kiểu riêng, ẩn danh. Vì vậy bạn không viết được tên kiểu đó. Có hai cách để trả về closure:
fn make_discount(percent: u32) -> impl Fn(u32) -> u32 {
move |price| price - price * percent / 100
}
fn pick_rule(is_vip: bool) -> Box<dyn Fn(u32) -> u32> {
if is_vip {
Box::new(|price| price * 80 / 100)
} else {
Box::new(|price| price)
}
}
fn main() {
let summer_sale = make_discount(15);
println!("{}", summer_sale(200));
let rule = pick_rule(true);
println!("{}", rule(500));
}
170
400
impl Fn(...)dùng khi hàm luôn trả về một kiểu closure cố định. Cách này không cấp phát heap và không có chi phí gọi động.Box<dyn Fn(...)>dùng khi các nhánh trả về closure khác nhau. Hai closure trongpick_rulecó hai kiểu khác nhau, nên phải đưa chúng về chung một trait object.movetrongmake_discountlà bắt buộc. Nếu thiếu, closure sẽ borrowpercent, màpercentbiến mất khi hàm kết thúc.
4. Trait Iterator
4.1. Cốt lõi: hàm next
Mọi iterator trong Rust đều xoay quanh một trait rất nhỏ:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// ... hàng chục method có sẵn như map, filter, sum ...
}
next trả về Some(phần_tử) cho tới khi hết dữ liệu, sau đó trả về None. Vòng for thực chất chỉ là cú pháp gọn cho việc gọi next liên tục.
4.2. iter, iter_mut và into_iter
Một collection có thể cho ra ba loại iterator khác nhau:
fn main() {
let mut prices = vec![100, 200, 300];
for p in prices.iter() { // p: &i32
print!("{p} ");
}
println!();
for p in prices.iter_mut() { // p: &mut i32
*p += 10;
}
for p in prices.into_iter() { // p: i32, vector bị move
print!("{p} ");
}
println!();
// println!("{:?}", prices); // lỗi: value borrowed after move
}
100 200 300
110 210 310
iter()cho tham chiếu bất biến. Collection vẫn dùng được sau đó.iter_mut()cho tham chiếu khả biến để sửa phần tử tại chỗ.into_iter()lấy ownership. Viếtfor p in pricescũng gọiinto_iter()ngầm định.
5. Adapter và consumer
Method trên iterator chia làm hai nhóm:
- Adapter (
map,filter,take,zip,enumerate…) biến iterator này thành iterator khác. Chúng lazy: không chạy gì cho tới khi có người tiêu thụ. - Consumer (
collect,sum,count,fold,for_each,find…) thực sự gọinextvà cho ra kết quả.
Ta dùng struct Order làm dữ liệu mẫu:
#[derive(Debug)]
pub struct Order {
pub id: u32,
pub customer: String,
pub total: u32,
pub paid: bool,
}
pub fn unpaid_total(orders: &[Order]) -> u32 {
orders.iter()
.filter(|o| !o.paid)
.map(|o| o.total)
.sum()
}
pub fn big_customers(orders: &[Order], threshold: u32) -> Vec<&str> {
orders.iter()
.filter(|o| o.total >= threshold)
.map(|o| o.customer.as_str())
.collect()
}
fn main() {
let orders = vec![
Order { id: 1, customer: "An".into(), total: 150_000, paid: true },
Order { id: 2, customer: "Bình".into(), total: 80_000, paid: false },
Order { id: 3, customer: "Chi".into(), total: 320_000, paid: false },
];
println!("Chưa thu: {}", unpaid_total(&orders));
println!("Khách lớn: {:?}", big_customers(&orders, 100_000));
let summary = orders.iter()
.fold(String::new(), |acc, o| acc + &format!("#{} ", o.id));
println!("Đơn: {summary}");
}
Chưa thu: 400000
Khách lớn: ["An", "Chi"]
Đơn: #1 #2 #3
Các bước trong ví dụ:
filter(|o| !o.paid)nhận closure trả vềbool. Tham sốocó kiểu&&Order, vìfiltertruyền tham chiếu tới phần tử, mà phần tử củaiter()đã là&Order. Auto-deref giúp bạn viếto.paidbình thường.map(|o| o.total)chuyển mỗi đơn thành số tiền.sum()cộng tất cả lại.collect()gom kết quả vào một collection. Kiểu đích lấy từ kiểu trả vềVec<&str>của hàm.foldnhận giá trị khởi tạo và một closure(acc, phần_tử) -> acc_mới. Đây là consumer tổng quát nhất.
5.1. Tính lazy
Đoạn sau cho thấy adapter chỉ chạy khi bị tiêu thụ:
fn main() {
let ids = vec![1, 2, 3];
let doubled = ids.iter().map(|x| {
println!("map {x}");
x * 2
});
println!("Chưa có gì chạy");
let result: Vec<i32> = doubled.collect();
println!("{result:?}");
}
Chưa có gì chạy
map 1
map 2
map 3
[2, 4, 6]
Nhờ tính lazy, chuỗi filter → map → sum chỉ duyệt dữ liệu một lần và không tạo collection trung gian. Compiler thường tối ưu pipeline này thành mã máy tương đương vòng lặp viết tay. Đây là lý do iterator được gọi là zero-cost abstraction.
6. Tự viết một iterator
Bạn chỉ cần implement next. Tất cả adapter còn lại đi kèm miễn phí. Ví dụ sau sinh số hóa đơn theo định dạng HD-0001:
pub struct InvoiceNumbers {
current: u32,
end: u32,
}
impl InvoiceNumbers {
pub fn new(start: u32, end: u32) -> Self {
Self { current: start, end }
}
}
impl Iterator for InvoiceNumbers {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
if self.current > self.end {
return None;
}
let number = format!("HD-{:04}", self.current);
self.current += 1;
Some(number)
}
}
fn main() {
let customers = ["An", "Bình", "Chi"];
for (invoice, customer) in InvoiceNumbers::new(7, 100).zip(customers) {
println!("{invoice} -> {customer}");
}
}
HD-0007 -> An
HD-0008 -> Bình
HD-0009 -> Chi
Giải thích:
type Item = Stringkhai báo kiểu phần tử iterator trả ra.nextcập nhật trạng thái qua&mut selfvà trảNonekhi vượt quáend.zipghép hai iterator thành cặp và dừng khi iterator ngắn hơn hết phần tử. Vì vậy chỉ có ba dòng được in, dù dải số đi tới 100.
7. Viết test
Rust có sẵn framework test. Bạn đặt module test ngay trong file chứa unpaid_total và InvoiceNumbers:
#[cfg(test)]
mod tests {
use super::*;
fn order(id: u32, total: u32, paid: bool) -> Order {
Order { id, customer: format!("KH{id}"), total, paid }
}
#[test]
fn unpaid_total_only_counts_unpaid_orders() {
let orders = vec![order(1, 100, true), order(2, 50, false), order(3, 70, false)];
assert_eq!(unpaid_total(&orders), 120);
}
#[test]
fn unpaid_total_of_empty_slice_is_zero() {
assert_eq!(unpaid_total(&[]), 0);
}
#[test]
fn invoice_numbers_stop_after_end() {
let numbers: Vec<String> = InvoiceNumbers::new(9, 11).collect();
assert_eq!(numbers, vec!["HD-0009", "HD-0010", "HD-0011"]);
}
}
cargo test
running 3 tests
test tests::invoice_numbers_stop_after_end ... ok
test tests::unpaid_total_of_empty_slice_is_zero ... ok
test tests::unpaid_total_only_counts_unpaid_orders ... ok
test result: ok. 3 passed; 0 failed; 0 ignored
Test thứ hai kiểm tra trường hợp biên. sum() trên iterator rỗng trả về 0, không panic.
8. Lỗi hay gặp và khi nào không nên dùng
Lưu ý: Quên consumer là lỗi phổ biến nhất. Dòng
orders.iter().map(|o| println!("{}", o.id));không in gì cả. Compiler cảnh báo “iterators are lazy and do nothing unless consumed”. Khi chỉ cần side effect, bạn hãy dùng vòngforhoặcfor_each.
Một số lỗi khác bạn sẽ gặp:
collectkhông biết kiểu đích. Viếtlet v = it.collect();sẽ lỗi “type annotations needed”. Bạn cần ghi rõlet v: Vec<_> = ...hoặc dùng turbofishcollect::<Vec<_>>().- Dùng
into_iter()rồi muốn dùng lại collection. Nếu chỉ cần đọc, hãy dùngiter(). - Closure giữ borrow quá lâu. Một closure
FnMutđang capture&mut cartkhiến bạn không đọc đượccartcho tới lần dùng cuối của closure đó. Hãy thu hẹp phạm vi closure hoặc gọi nó xong rồi mới đọc. - Cần
?bên trongmap. Toán tử?trong closure chỉ return khỏi closure, không return khỏi hàm bao ngoài. Bạn hãy dùngcollect::<Result<Vec<_>, _>>(). Cách này dừng ngay ở lỗi đầu tiên và trả vềErr.
Khi nào không nên ép mọi thứ thành chuỗi iterator:
- Logic có nhiều nhánh, nhiều điểm
break/continue, hoặc cập nhật nhiều biến cùng lúc. Một vòngforrõ ràng dễ đọc hơn mộtfolddài mười dòng. - Bạn cần trả về closure từ nhiều nhánh nhưng lại lo về hiệu năng.
Box<dyn Fn>có chi phí cấp phát và gọi động. Hãy cân nhắc dùngenumhoặc generic thay thế.
9. Kết luận
Closure trong Rust capture biến theo cách nhẹ nhất có thể: borrow, borrow khả biến, hoặc move. Từ đó closure tự động implement Fn, FnMut hoặc FnOnce. Khi viết hàm nhận closure, bạn hãy chọn trait lỏng nhất mà hàm cần.
Iterator chỉ dựa trên một method next. Adapter như map, filter, zip là lazy, còn consumer như collect, sum, fold mới thực sự chạy pipeline. Kết hợp hai công cụ này, bạn viết được code xử lý dữ liệu ngắn gọn, an toàn bộ nhớ và nhanh ngang vòng lặp thủ công. Bạn cũng nên biết lúc nào một vòng for đơn giản là lựa chọn tốt hơn.