🧠 Ownership và borrowing khi xử lý tiền

By Everwise Intern

Tiền là dữ liệu không được copy bừa

Đây là bài đầu tiên của khoá. Bạn đã biết Java, C#, hay Python, nghĩa là bạn đã quen với ý tưởng "ai đang giữ object này". Rust chỉ khác ở chỗ: nó bắt bạn trả lời câu hỏi đó ngay lúc compile, và không cho bạn lách bằng cách copy mọi thứ. Trong code ngân hàng, một đồng bị tính hai lần là lỗi P0, nên sự khó tính của borrow checker thực ra là đồng minh của bạn.

Sau bài này, bạn sẽ:

  • Đọc được lỗi E0502, E0499, E0515 và biết mình vừa vi phạm quy tắc nào
  • Chọn đúng &T, &mut T hay truyền hẳn quyền sở hữu (move) cho hàm
  • Bỏ được những .clone() chỉ sinh ra để dỗ borrow checker
  • Giải thích được vì sao transfer(&mut a, &mut a, 100) không compile

Hãy nhớ ba quy tắc gốc, vì cả bài chỉ xoay quanh chúng: mỗi giá trị có _____ owner tại một thời điểm; owner ra khỏi scope thì value bị drop; và bạn có thể mượn giá trị theo hai kiểu.

Chuyển quyền sở hữu khi account đổi tay

Kiểu dữ liệu tiền nên khai báo bằng đơn vị nhỏ nhất, thường là i64 cents, để tránh sai số floating point. Điểm quan trọng: i64 implement trait _____, nên truyền nó vào hàm là copy chứ không move. Vì vậy các con số hiếm khi gây lỗi ownership. Thứ gây lỗi là phần "vỏ" của account:

#[derive(Debug, Clone)]
struct Account {
    id: String,          // không Copy
    currency: String,    // không Copy
    balance_cents: i64,  // Copy
}

impl Account {
    fn deposit(&mut self, amount_cents: i64) -> Option<i64> {
        if amount_cents <= 0 {
            return None;
        }
        self.balance_cents = self.balance_cents.checked_add(amount_cents)?;
        Some(self.balance_cents)
    }
}

checked_add trả về Option<i64>, nên overflow được xử lý tường minh thay vì panic hoặc wrap âm thầm. Đây là thói quen bắt buộc trong luồng tiền.

Khi nào nên move? Khi hàm thật sự tiếp quản dữ liệu: ví dụ hàm close_account(account: Account) tiêu thụ account và không ai được dùng lại nó sau đó. Move là cách tốt nhất để biến "đã đóng thì không giao dịch được nữa" thành ràng buộc của compiler.

Note: Move không làm mất dữ liệu, nó chỉ chuyển quyền. Sau khi move, biến cũ không dùng được nữa và compiler báo lỗi ngay tại dòng bạn dùng lại, chứ không để bạn phát hiện lúc chạy production.

Mượn đọc bằng &T, mượn ghi bằng &mut T

Quy tắc borrow: tại một thời điểm có thể có nhiều _____ (&T) hoặc đúng một _____ (&mut T), nhưng không được có cả hai cùng lúc.

Nhu cầu Chọn Ghi chú
Chỉ đọc số dư để hiển thị &Account Gọi bao nhiêu lần cũng được, không side effect
Trừ tiền, cộng tiền &mut Account Độc quyền, người khác không đọc được cùng lúc
Trả số dư ra ngoài i64 Copy, rẻ, không kéo theo lifetime
Đẩy account sang task async khác move hoặc clone có chủ đích Boundary thật sự cần sở hữu

Một ca kinh điển trong code ngân hàng:

fn transfer(from: &mut Account, to: &mut Account, amount_cents: i64) -> Result<(), &'static str> {
    if from.balance_cents < amount_cents {
        return Err("insufficient funds");
    }
    from.balance_cents -= amount_cents;
    to.balance_cents = to.balance_cents.checked_add(amount_cents).ok_or("overflow")?;
    Ok(())
}

// transfer(&mut a, &mut a, 100); // E0499: hai mutable borrow trên cùng một giá trị

Lỗi này không phải phiền toái, nó là nghiệp vụ được kiểm tra tự động: cùng một account không thể vừa là nguồn vừa là đích trong cùng một lời gọi. Nếu sản phẩm cho phép self-transfer, hãy viết nhánh riêng chứ đừng tìm cách lách borrow checker.

Đọc lỗi borrow checker trong code tính số dư

Ca thường gặp nhất: giữ một reference tới field rồi sau đó mutate chính account đó. Ví dụ:

fn main() {
    let mut acc = Account { id: String::from("ACC-001"), currency: String::from("VND"), balance_cents: 0 };
    let id = &acc.id;        // immutable borrow đang sống
    acc.deposit(100_000);    // E0502: vừa mutable borrow khi đang có immutable borrow
    println!("{}", id);
}

Ba cách sửa, chọn theo ngữ nghĩa chứ không theo cách nào ngắn hơn:

  1. Đảo thứ tự: println! trước rồi mới deposit. Thường là cách đúng nhất.
  2. Thu hẹp scope bằng một block { } quanh phần đọc.
  3. Clone id nếu bạn thật sự cần giữ nó qua lần mutation. Đây là clone chính đáng.

Lỗi E0515 xuất hiện khi bạn trả reference tới dữ liệu cục bộ:

fn balance_ref(acc: &Account) -> &i64 {
    let snapshot = acc.balance_cents; // biến cục bộ
    &snapshot                          // E0515: snapshot bị drop khi hàm kết thúc
}

Cách sửa: trả i64 trực tiếp. Với tiền, trả by value thường đúng hơn trả reference. Một chi tiết hữu ích: borrow checker cho phép mượn rời từng field, nghĩa là &mut acc.balance_cents và &acc.id có thể tồn tại cùng lúc vì chúng là hai field khác nhau.

Bài tập 1: vì sao đoạn này không compile?

fn transfer(from: &mut Account, to: &mut Account, amount: i64) { /* ... */ }

fn main() {
    let mut a = Account { id: "ACC-001".into(), currency: "VND".into(), balance_cents: 500 };
    transfer(&mut a, &mut a, 100);
}

Gợi ý đáp án: lỗi E0499, hai mutable borrow độc quyền trên cùng a. Sửa bằng cách xử lý self-transfer bằng hàm riêng (ví dụ fn self_transfer(acc: &mut Account, amount: i64)), hoặc validate from.id != to.id ở tầng nghiệp vụ trước khi gọi. Điều quan trọng là bạn nhận ra compiler đang chặn một tình huống nghiệp vụ vô nghĩa.

Bài tập 2: bỏ clone không cần thiết

fn total_fee(fees: &Vec<i64>) -> i64 {
    let copy = fees.clone();
    copy.iter().sum()
}

Gợi ý đáp án: fees.iter().sum() là đủ, không cần clone. Đồng thời đổi tham số thành fees: &[i64] để nhận được cả slice lẫn Vec, và vì hàm chỉ đọc nên không cần quyền sở hữu.

Thẻ nhớ: phản xạ ownership khi tính tiền

Front: Một giá trị trong Rust có bao nhiêu owner tại một thời điểm? Back: Đúng một owner; owner ra khỏi scope thì value bị drop.

Front: Vì sao hàm deposit phải nhận &mut self? Back: Vì thân hàm ghi vào field, cần mutable borrow độc quyền.

Front: Vì sao transfer(&mut a, &mut a, 100) không compile? Back: Hai mutable borrow trên cùng một giá trị, lỗi E0499.

Front: i64 có Copy không, hệ quả là gì? Back: Có, truyền vào hàm là copy chứ không move, nên số tiền hiếm khi gây lỗi move.

Front: Kiểu nào trong Account không Copy và dễ gây lỗi move? Back: String, Vec và các struct không derive Copy.

Front: Lỗi E0502 nói điều gì? Back: Không thể mutable borrow khi giá trị đang được immutable borrow.

Front: Khi nào clone là chính đáng? Back: Ở boundary cần sở hữu thật: spawn task, gửi vào channel, hoặc cần giữ giá trị qua một lần mutation khác.

Front: checked_add trả về gì? Back: Option, None khi overflow, buộc bạn xử lý thay vì panic.

Front: Vì sao tiền nên lưu bằng i64 đơn vị nhỏ nhất? Back: Tránh sai số floating point, và kết hợp checked_add để bắt overflow.

Front: Trả reference tới biến cục bộ thì lỗi gì? Back: E0515: không thể trả reference tới dữ liệu sẽ bị drop khi hàm kết thúc.

Front: Có thể mượn mutable hai field khác nhau cùng lúc không? Back: Được, borrow checker cho phép mượn rời từng field của cùng một struct.

Front: Dấu hiệu của một clone không cần thiết? Back: Clone xuất hiện ngay sau khi gặp lỗi borrow checker, hoặc clone cả struct chỉ để đọc một field.

Ba cạm bẫy khi tính số dư bằng Rust

  • Clone để dỗ compiler là nợ kỹ thuật. Mỗi lần bạn định thêm .clone(), hãy thử đảo thứ tự lệnh, thu hẹp scope, hoặc đổi chữ ký hàm trước. Nếu vẫn cần, ghi comment một dòng nói rõ vì sao cần sở hữu.
  • &mut là hợp đồng, không phải thủ tục. Hàm chỉ đọc thì đừng nhận &mut; nó chặn mọi caller khác và làm code gọi trở nên khó song song hoá về sau.
  • Đừng trả reference ra khỏi domain model. Trả i64 hoặc một struct snapshot. Reference tới field nội bộ sẽ kéo lifetime lan ra khắp nơi và làm API khó đổi.
  • Quy tắc ngón tay: nếu hàm không giữ lại dữ liệu sau khi return, hãy borrow. Nếu nó lưu vào struct khác hoặc đẩy sang task khác, hãy move.

Chốt lại và bước tiếp theo

Ba điều cần mang theo: (1) tiền dùng i64 đơn vị nhỏ nhất kèm checked_*, (2) đọc thì &T, ghi thì &mut T, tiếp quản thì move, (3) mỗi lỗi borrow checker là một câu hỏi về quyền sở hữu, không phải một trở ngại cần lách.

Bài sau, "Lifetimes và domain model tài chính", sẽ đi tiếp đúng chỗ bài này dừng lại: khi bạn bắt buộc phải trả reference hoặc lưu reference trong struct, lifetime annotation là cách để nói với compiler rằng dữ liệu đó sống đủ lâu.