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_cart chỉ đọc cart, nên nó giữ một &cart.
  • add_item gọi push, nên nó cần &mut cart. Biến closure cũng phải khai báo mut, 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_cart khô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ào run_twice sẽ lỗi.
  • Closure cuối dùng name + "...". Toán tử + trên String lấy ownership của name, nên closure chỉ gọi được một lần và chỉ implement FnOnce.

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 trong pick_rule có hai kiểu khác nhau, nên phải đưa chúng về chung một trait object.
  • move trong make_discount là bắt buộc. Nếu thiếu, closure sẽ borrow percent, mà percent biế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ết for p in prices cũng gọi into_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ọi next và 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ố o có kiểu &&Order, vì filter truyền tham chiếu tới phần tử, mà phần tử của iter() đã là &Order. Auto-deref giúp bạn viết o.paid bì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.
  • fold nhậ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 = String khai báo kiểu phần tử iterator trả ra.
  • next cập nhật trạng thái qua &mut self và trả None khi vượt quá end.
  • zip ghé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òng for hoặc for_each.

Một số lỗi khác bạn sẽ gặp:

  • collect không biết kiểu đích. Viết let v = it.collect(); sẽ lỗi “type annotations needed”. Bạn cần ghi rõ let v: Vec<_> = ... hoặc dùng turbofish collect::<Vec<_>>().
  • Dùng into_iter() rồi muốn dùng lại collection. Nếu chỉ cần đọc, hãy dùng iter().
  • Closure giữ borrow quá lâu. Một closure FnMut đang capture &mut cart khiến bạn không đọc được cart cho 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 trong map. 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ùng collect::<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òng for rõ ràng dễ đọc hơn một fold dà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ùng enum hoặ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.

rustiteratorclosurefunctional programming