Giới thiệu
Ngôn ngữ lập trình Rust bằng tiếng Việt.
Rust là gì
Rust là ngôn ngữ lập trình hệ thống: hiệu năng và khả năng kiểm soát bộ nhớ ngang C/C++, nhưng an toàn bộ nhớ được đảm bảo ngay tại thời điểm biên dịch thay vì phải chờ garbage collector chạy lúc runtime. Nhờ vậy Rust vừa nhanh vừa hạn chế được cả lớp lỗi phổ biến trong C/C++ (dangling pointer, data race, use-after-free, …) mà không phải đánh đổi hiệu năng để lấy sự an toàn đó.
Sách này dành cho ai
Không yêu cầu bạn đã thành thạo lập trình, nhưng cũng không phải tài liệu nhập môn lập trình từ con số 0. Bạn nên có sẵn kiến thức nền tảng — biến, hàm, vòng lặp, kiểu dữ liệu — hoặc từng viết code ở mức cơ bản trước khi đọc sách này; nội dung sẽ tập trung vào những gì đặc trưng của Rust thay vì giảng lại các khái niệm lập trình phổ quát.
Phạm vi
Tài liệu ra đời sau và không có tham vọng thay thế các nguồn đã có, nên được viết cô đọng, chỉ đi vào phần cốt lõi: cài đặt và sử dụng phiên bản Rust ổn định (stable) mới nhất, với hướng dẫn thiết lập môi trường trên macOS. Các hệ điều hành khác không nằm trong phạm vi hướng dẫn thiết lập của sách.
Bắt đầu
Trước khi viết dòng code Rust đầu tiên, cần có ba thứ trên máy: trình biên dịch Rust, Cargo — công cụ build kiêm quản lý dependency đi kèm, và cargo-binstall — tiện ích cài nhanh các công cụ dòng lệnh viết bằng Rust mà không phải build từ mã nguồn. Phần này hướng dẫn cài đặt cả ba trên macOS, dùng phiên bản Rust ổn định (stable) mới nhất.
Cài đặt Rust với rustup
rustup là công cụ chính thức để cài đặt và quản lý phiên bản Rust trên máy: tải toolchain, chuyển
đổi giữa các kênh phát hành (stable/beta/nightly), thêm target để biên dịch chéo, và khoá phiên bản
riêng cho từng dự án. Cài bằng script chính thức:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Script sẽ hỏi vài tuỳ chọn cài đặt, chọn 1) Proceed with installation (default) là đủ cho hầu hết
trường hợp. Sau khi cài xong, mở lại terminal (hoặc chạy source "$HOME/.cargo/env") để rustc và
cargo có trong PATH.
Kiểm tra đã cài đúng:
rustc --version
Lệnh trên in ra phiên bản stable hiện có trên máy, ví dụ rustc 1.82.0 (...). Khi có bản mới hơn,
cập nhật bằng:
rustup update
Ngoài bản mặc định, có thể chủ động cài thêm toolchain khác — một phiên bản cụ thể, hoặc kênh
beta/nightly — song song mà không ảnh hưởng tới bản đang dùng:
rustup toolchain install 1.90.0
rustup toolchain install beta
rustup toolchain install nightly
Toolchain vừa cài không tự động trở thành mặc định. Muốn chạy thử một lệnh với toolchain cụ thể mà
không đổi mặc định, thêm tiền tố +<toolchain>:
cargo +1.90.0 build
Muốn đổi hẳn sang toolchain đó làm mặc định cho toàn máy:
rustup default 1.90.0
Xem toàn bộ toolchain đã cài trên máy — có thể có nhiều hơn một nếu từng cài thêm bản beta/nightly hoặc một phiên bản cụ thể để thử:
rustup toolchain list
Gỡ một toolchain không dùng tới nữa:
rustup toolchain uninstall nightly
Target biên dịch
Một target (target triple) mô tả nền tảng sẽ chạy binary sinh ra — kiến trúc CPU, hệ điều hành, và
ABI, ví dụ aarch64-apple-darwin cho Mac Apple Silicon hay wasm32-unknown-unknown cho
WebAssembly. Khi cài rustup, chỉ target khớp với máy đang dùng được cài sẵn — đủ để build và chạy
ngay trên máy, nhưng chưa đủ nếu muốn biên dịch chéo (cross-compile) sang nền tảng khác.
Xem target đã cài và toàn bộ target khả dụng:
rustup target list --installed
rustup target list
Thêm một target mới:
rustup target add wasm32-unknown-unknown
Gỡ một target không cần nữa:
rustup target remove wasm32-unknown-unknown
Cấu hình phiên bản Rust cho từng dự án
Mặc định rustup dùng một toolchain chung cho toàn máy. Một dự án cụ thể có thể cần khoá cứng ở một
phiên bản hoặc kênh khác — khai báo trong file rust-toolchain.toml ở thư mục gốc dự án:
[toolchain]
channel = "stable"
Mỗi khi chạy rustc/cargo trong thư mục có file này (hoặc thư mục con của nó), rustup tự nhận ra
và dùng đúng phiên bản khai báo, tự tải về nếu máy chưa có — không cần đổi gì thủ công. Muốn khoá
một bản cụ thể thay vì luôn theo stable mới nhất, thay giá trị channel bằng số hiệu, ví dụ
channel = "1.82.0". File này nằm trong dự án nên commit vào git, để ai clone về cũng dùng đúng
phiên bản.
Nếu chỉ cần đổi phiên bản tạm thời trên máy cá nhân, không muốn ghi vào file chia sẻ qua git, dùng override theo thư mục thay thế:
rustup override set 1.82.0
Gỡ override để quay lại dùng toolchain mặc định của máy:
rustup override unset
Xem toàn cảnh trạng thái hiện tại — toolchain nào đang active, active vì lý do gì (default hay override), và những target nào đã cài cho toolchain đó:
rustup show
Cargo
Cargo được cài kèm tự động cùng rustup, không cần cài riêng. Đây là build tool kiêm trình quản lý
dependency của Rust: biên dịch, chạy, test, và tải các crate (thư viện) mà dự án phụ thuộc vào. Toàn
bộ ví dụ trong sách chạy qua cargo thay vì gọi rustc trực tiếp.
Kiểm tra đã có Cargo:
cargo --version
cargo-binstall
cargo install cài một binary bằng cách biên dịch từ mã nguồn: tải crate, tải toàn bộ dependency,
rồi compile — với những crate lớn có thể mất vài phút và cần đủ toolchain để build.
cargo-binstall là một subcommand thay thế: tìm bản
binary đã build sẵn của đúng crate đó đính kèm trong GitHub Releases, khớp với target của máy đang
dùng, tải về, kiểm tra tính toàn vẹn rồi cài thẳng — bỏ qua hoàn toàn bước compile, thường chỉ mất
vài giây thay vì vài phút.
Nếu crate không có sẵn bản build cho target đang dùng, cargo-binstall tự động rơi về biên dịch từ mã
nguồn giống hệt cargo install. Nhờ vậy cargo binstall <tên-crate> dùng thay được cho
cargo install <tên-crate> trong mọi trường hợp mà không mất khả năng cài đặt — chỉ được lợi thêm
tốc độ khi có sẵn bản build. Sách này dùng cargo binstall bất cứ khi nào cần cài một công cụ dòng
lệnh viết bằng Rust.
Cài bằng script chính thức — tải sẵn binary, không cần cài thêm công cụ nào khác ngoài curl (đã có
sẵn trên macOS):
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
Kiểm tra (lưu ý dùng -V chứ không phải --version — cờ --version của cargo-binstall dùng để chỉ
định phiên bản của package cần cài, không phải để in phiên bản của chính công cụ):
cargo binstall -V
Khái niệm lập trình cơ bản
Chương này đi qua những khối xây dựng nhỏ nhất của một chương trình Rust: cách khai báo giá trị, kiểu dữ liệu có sẵn, cách gói code thành hàm, và cách rẽ nhánh/lặp. Đây là những khái niệm phổ quát ở hầu hết ngôn ngữ lập trình, nên phần này chỉ tập trung vào chỗ Rust khác biệt hoặc có quy tắc riêng, không giảng lại từ đầu khái niệm “biến là gì”, “hàm là gì”.
Biến và tính khả biến
Trong Rust, biến khai báo bằng let mặc định là bất biến — một khi đã gán giá trị, không thể
gán lại:
fn main() {
let x = 5;
println!("Giá trị của x là: {x}");
x = 6;
println!("Giá trị của x là: {x}");
}
Biên dịch đoạn trên báo lỗi ngay tại dòng x = 6: gán lại một biến bất biến. Đây không phải giới
hạn ngôn ngữ mà là lựa chọn thiết kế có chủ đích: khi một giá trị được khai báo bất biến, cả người
đọc code lẫn trình biên dịch đều biết chắc nó sẽ không đổi ở bất kỳ đâu về sau, không cần dò theo
toàn bộ hàm để xác nhận. Lợi ích này càng rõ khi nhiều phần code cùng thấy một giá trị — tính bất
biến loại bỏ hẳn khả năng một phần code âm thầm thay đổi thứ mà phần khác đang dựa vào.
Muốn một biến có thể đổi giá trị, khai báo rõ bằng mut:
fn main() {
let mut x = 5;
println!("Giá trị của x là: {x}");
x = 6;
println!("Giá trị của x là: {x}");
}
mut không chỉ là công tắc bật/tắt lỗi biên dịch — nó là tín hiệu đọc-được cho biết “giá trị này sẽ
đổi”, giúp người đọc code khoanh vùng đúng chỗ cần chú ý khi tìm lỗi liên quan tới trạng thái.
Hằng số
const cũng khai báo một giá trị không đổi, nhưng khác let (không có mut) ở ba điểm: luôn phải
chú thích kiểu tường minh, giá trị phải tính được ngay tại thời điểm biên dịch (không thể là kết quả
một lời gọi hàm chỉ biết lúc chạy), và có thể khai báo ở phạm vi toàn cục (ngoài mọi hàm), trong khi
let chỉ hợp lệ bên trong một hàm hoặc block. Theo quy ước, tên hằng số viết hoa toàn bộ, các từ
cách nhau bằng _:
#![allow(unused)]
fn main() {
const SO_GIAY_MOI_GIO: u32 = 60 * 60;
}
Shadowing
Khai báo một let mới cùng tên với biến đã có sẽ shadowing (che khuất) biến cũ — từ điểm đó trở
đi, tên đó trỏ tới giá trị mới:
fn main() {
let x = 5;
let x = x + 1;
{
let x = x * 2;
println!("Giá trị của x trong scope trong: {x}");
}
println!("Giá trị của x: {x}");
}
Shadowing khác mut ở bản chất: mỗi let tạo ra một biến hoàn toàn mới (vẫn bất biến theo mặc
định), không phải ghi đè giá trị của biến cũ. Nhờ vậy shadowing cho phép đổi cả kiểu dữ liệu giữa
các bước biến đổi — điều mut không cho phép, vì một biến mut phải giữ nguyên kiểu suốt vòng đời
của nó:
fn main() {
let spaces = " ";
let spaces = spaces.len();
println!("Số khoảng trắng: {spaces}");
}
Ở đây spaces đi từ kiểu &str sang usize qua hai lần let — hợp lệ vì đây là hai biến khác
nhau tình cờ trùng tên. Nếu thử làm điều này với mut spaces thay vì shadowing, trình biên dịch sẽ
báo lỗi sai kiểu ngay tại phép gán.
Kiểu dữ liệu
Rust là ngôn ngữ định kiểu tĩnh: kiểu của mọi giá trị phải xác định được tại thời điểm biên dịch. Phần lớn thời gian trình biên dịch tự suy luận ra kiểu từ giá trị và cách dùng, không cần viết tường minh. Suy luận chỉ bó tay khi một giá trị có thể ứng với nhiều kiểu đích hợp lệ — ví dụ parse một chuỗi thành số:
fn main() {
let guess = "42".parse().expect("Không phải số");
}
parse có thể trả về bất kỳ kiểu số nào implement trait tương ứng, nên trình biên dịch không biết
chọn kiểu nào. Phải chú thích tường minh:
fn main() {
let guess: u32 = "42".parse().expect("Không phải số");
println!("{guess}");
}
Kiểu vô hướng (scalar)
Một giá trị scalar biểu diễn đúng một giá trị đơn lẻ. Rust có bốn kiểu scalar chính: số nguyên, số thực dấu phẩy động, boolean, và ký tự.
Số nguyên có dấu (i8, i16, i32, i64, i128, isize) và không dấu (u8…u128,
usize), số trong tên là số bit chiếm dụng — quyết định khoảng giá trị biểu diễn được. isize/
usize có kích thước bằng kiến trúc máy (64 bit trên macOS hiện đại), dùng chủ yếu để đánh chỉ mục
(index) vào collection. Không chú thích gì, mặc định là i32 — thường là lựa chọn nhanh nhất trên
đa số kiến trúc.
Một chi tiết dễ bị bỏ qua nhưng quan trọng: khi phép tính vượt quá khoảng giá trị của kiểu (integer
overflow), hành vi khác nhau giữa hai profile biên dịch. Build debug (mặc định của cargo build
hoặc cargo run) sẽ panic ngay khi overflow xảy ra. Build release (cờ --release) không panic
— giá trị “cuộn vòng” (wrap) theo số học modulo, ví dụ một u8 cộng vượt 255 sẽ quay về 0. Sự khác
biệt này tồn tại vì kiểm tra overflow có chi phí runtime; Rust chấp nhận bắt lỗi sớm khi đang phát
triển (debug) nhưng ưu tiên tốc độ khi đã release. Muốn xử lý overflow một cách tường minh, chủ động
thay vì phó mặc cho hành vi mặc định của profile, dùng các method như wrapping_add, checked_add,
saturating_add thay vì toán tử + trần.
Số thực dấu phẩy động có f32 và f64 (theo chuẩn IEEE 754), mặc định là f64 — trên các CPU
hiện đại, f64 và f32 có tốc độ tính toán tương đương nhau nhưng f64 cho độ chính xác cao hơn.
Boolean (bool) chỉ có hai giá trị true/false, chiếm 1 byte.
Ký tự (char) biểu diễn một Unicode scalar value, chiếm 4 byte — rộng hơn nhiều so với 1 byte
ASCII. Một char có thể biểu diễn chữ cái có dấu, chữ Hán, emoji, hay bất kỳ ký tự nào trong chuẩn
Unicode, không riêng gì bảng chữ cái Latin:
fn main() {
let c = 'z';
let chu_a: char = 'A';
let icon = '😻';
println!("{c} {chu_a} {icon}");
}
Kiểu phức hợp (compound)
Kiểu phức hợp gộp nhiều giá trị vào một kiểu duy nhất. Rust có hai kiểu phức hợp nguyên thuỷ: tuple và mảng (array).
Tuple gom một số lượng giá trị cố định, có thể khác kiểu nhau, thành một giá trị:
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
let (x, y, z) = tup;
println!("Giá trị của y là: {y}");
println!("Phần tử đầu: {}", tup.0);
}
Có thể lấy từng phần tử ra bằng destructuring (let (x, y, z) = tup) hoặc truy cập trực tiếp bằng
chỉ số sau dấu chấm (tup.0). Một tuple không chứa gì cả — () — gọi là unit; đây cũng là kiểu
trả về mặc định của một biểu thức không trả về giá trị có ý nghĩa nào.
Mảng gom nhiều giá trị cùng kiểu, độ dài cố định ngay tại thời điểm biên dịch — khác với
kiểu Vec sẽ gặp ở chương Collection, có thể tăng giảm
kích thước lúc chạy. Vì độ dài cố định, một mảng được cấp phát trên stack thay vì heap (khái niệm
stack/heap sẽ rõ hơn ở chương Quyền sở hữu) — phù hợp khi biết chắc số lượng phần tử không đổi, ví
dụ tên các tháng trong năm:
fn main() {
let thang: [&str; 3] = ["Một", "Hai", "Ba"];
let so_khoi_tao = [3; 5]; // tương đương [3, 3, 3, 3, 3]
println!("Tháng đầu tiên: {}", thang[0]);
println!("{:?}", so_khoi_tao);
}
Truy cập một chỉ số vượt quá độ dài mảng sẽ panic ngay lúc chạy (runtime) thay vì âm thầm đọc vùng nhớ không thuộc về mảng — Rust luôn kiểm tra chỉ số hợp lệ trước khi truy cập, kể cả khi việc đó có thêm chi phí kiểm tra ở mỗi lần truy cập.
Hàm
Hàm khai báo bằng fn, tên theo quy ước snake_case (chữ thường, cách nhau bằng _). Vị trí khai
báo không quan trọng — có thể gọi một hàm trước khi nó được định nghĩa ở phía dưới trong cùng phạm
vi, miễn trình biên dịch thấy được định nghĩa đó ở đâu đó:
fn main() {
println!("Hello, world!");
chao(5);
}
fn chao(x: i32) {
println!("Giá trị truyền vào: {x}");
}
Khác với biến cục bộ (thường suy luận được kiểu), tham số hàm luôn phải chú thích kiểu tường
minh. Đây là lựa chọn thiết kế có chủ đích: chữ ký hàm trở thành một dạng tài liệu — chỉ cần đọc
fn chao(x: i32) là biết ngay hàm nhận gì, không cần suy luận ngược từ phần thân hàm hay từ nơi
gọi.
Statement và expression
Phần thân hàm là một chuỗi statement, có thể kết thúc bằng một expression. Phân biệt hai
khái niệm này là chìa khoá để hiểu vì sao Rust viết được những đoạn code như dưới đây mà không cần
return:
- Statement thực hiện một hành động nhưng không trả về giá trị.
let y = 6;là statement — bản thân phép gán không phải là một giá trị, nên không thể viếtlet x = (let y = 6);. - Expression được tính toán ra một giá trị.
5 + 6, một lời gọi hàm, hay một khối{}đều là expression.
Điểm dễ nhầm nhất: một khối {} cũng là expression, và giá trị của nó là dòng cuối cùng bên trong —
miễn dòng đó không có dấu ;. Thêm ; biến một expression thành statement, huỷ luôn giá trị
của nó:
fn main() {
let y = {
let x = 3;
x + 1
};
println!("Giá trị của y: {y}");
}
x + 1 không có dấu ; nên là giá trị của cả khối, gán được cho y. Nếu viết x + 1; thì khối đó
trả về () (unit) thay vì 4.
Giá trị trả về
Kiểu trả về khai báo sau ->. Giá trị trả về chính là expression cuối cùng trong thân hàm — không
cần return ở cuối hàm, và cũng theo đúng quy tắc “không dấu ;” vừa nói ở trên vì thân hàm cũng
là một khối:
fn cong_mot(x: i32) -> i32 {
x + 1
}
fn main() {
let x = cong_mot(5);
println!("Giá trị của x: {x}");
}
Vẫn có thể return sớm khi cần thoát hàm trước khi chạy tới dòng cuối, nhưng ở dòng cuối cùng, một
expression trần (không return, không ;) là cách viết thành ngữ Rust dùng phổ biến nhất.
Luồng điều khiển
if là một expression
if không cần đặt điều kiện trong ngoặc, nhưng bắt buộc điều kiện phải là bool — khác C, nơi 0
được coi là false và mọi giá trị khác 0 được coi là true. Viết if number { ... } với number là
số nguyên chứ không phải bool là lỗi biên dịch ngay, không đợi tới lúc chạy mới lộ ra hành vi sai.
Vì if/else là một expression (giống khối {} ở phần Hàm), có thể dùng trực tiếp ở vế phải của
let:
fn main() {
let dieu_kien = true;
let so = if dieu_kien { 5 } else { 6 };
println!("Giá trị của số: {so}");
}
Vì cả hai nhánh phải cùng góp phần tạo ra giá trị của cùng một expression, kiểu trả về của mọi
nhánh if/else if/else phải giống nhau — trộn { 5 } và { "sáu" } là lỗi biên dịch, vì
trình biên dịch không thể biết so sẽ mang kiểu gì.
Lặp với loop, while, for
loop lặp vô hạn cho tới khi gặp break tường minh. Vì loop cũng là một expression, break
có thể mang theo giá trị — đó chính là kết quả của cả vòng lặp:
fn main() {
let mut dem = 0;
let ket_qua = loop {
dem += 1;
if dem == 10 {
break dem * 2;
}
};
println!("Kết quả: {ket_qua}");
}
Khi lồng nhiều loop, break/continue mặc định chỉ tác động vòng lặp trong cùng. Gắn nhãn
('nhan:) trước một vòng lặp để break/continue từ bên trong nhắm đúng vòng lặp ngoài:
fn main() {
let mut dem = 0;
'dem_ngoai: loop {
let mut dem_trong = 10;
loop {
if dem_trong == 9 {
break;
}
if dem == 2 {
break 'dem_ngoai;
}
dem_trong -= 1;
}
dem += 1;
}
println!("Kết thúc với dem = {dem}");
}
while lặp khi điều kiện còn đúng — hợp lý khi điều kiện dừng không tự nhiên là “đã duyệt hết
một tập giá trị” mà là một điều kiện tuỳ ý.
for lặp qua từng phần tử của một collection, là cách lặp thành ngữ và phổ biến nhất trong Rust
— an toàn hơn dùng while với chỉ số thủ công (không có rủi ro tính sai điều kiện dừng gây truy cập
ngoài phạm vi mảng), và không có chi phí kiểm tra biên ở mỗi vòng như cách lặp bằng chỉ số:
fn main() {
let mang = [10, 20, 30, 40, 50];
for phan_tu in mang {
println!("Giá trị: {phan_tu}");
}
for so in (1..4).rev() {
println!("{so}!");
}
println!("Bắt đầu!");
}
(1..4) tạo một Range từ 1 đến 3 (không bao gồm 4), .rev() đảo chiều thứ tự duyệt. for xuất
hiện lại xuyên suốt cuốn sách này, đặc biệt gắn liền với Iterator — trait sẽ được nói kỹ ở
chương Closure và Iterator.
Quyền sở hữu
Đây là tính năng đặc trưng nhất của Rust — cơ chế cho phép quản lý bộ nhớ an toàn mà không cần một garbage collector chạy nền, và không bắt người viết code tự gọi free thủ công như C. Toàn bộ việc kiểm tra diễn ra tại thời điểm biên dịch, không có chi phí lúc chạy.
Vì sao cần quyền sở hữu
Một chương trình phải quản lý bộ nhớ nó dùng lúc chạy, theo hai vùng khác nhau:
- Stack: lưu theo thứ tự LIFO (vào sau ra trước), chỉ chứa được dữ liệu có kích thước biết trước và cố định tại thời điểm biên dịch. Cấp phát/giải phóng trên stack chỉ là dịch chuyển một con trỏ, cực nhanh.
- Heap: chứa được dữ liệu có kích thước không biết trước hoặc có thể thay đổi lúc chạy (ví dụ một chuỗi người dùng nhập vào). Cấp phát trên heap phải nhờ bộ cấp phát (allocator) tìm một vùng trống đủ lớn, trả về con trỏ tới đó — chậm hơn stack, và vùng nhớ này phải được giải phóng lại đúng một lần khi không còn dùng nữa.
C giao toàn bộ việc giải phóng heap cho người viết code tự gọi free, dễ dẫn tới hai lỗi kinh điển:
quên gọi (rò rỉ bộ nhớ) hoặc gọi hai lần / dùng lại sau khi đã giải phóng (undefined behavior).
Nhiều ngôn ngữ khác né hai lỗi đó bằng garbage collector quét bộ nhớ lúc chạy để tự dọn — an toàn
nhưng có chi phí runtime. Rust chọn cách thứ ba: một tập quy tắc để trình biên dịch tự suy ra
chính xác thời điểm giải phóng, kiểm tra toàn bộ lúc biên dịch, không cần theo dõi gì lúc chạy.
Ba quy tắc
- Mỗi giá trị trong Rust có đúng một biến gọi là owner (chủ sở hữu) của nó.
- Tại một thời điểm, chỉ có một owner.
- Khi owner ra khỏi scope, giá trị bị drop — bộ nhớ được giải phóng ngay lập tức, không cần chờ.
Xét kiểu String — khác với string literal (&str, có độ dài cố định biết trước, nằm sẵn trong
binary), String cấp phát trên heap để chứa nội dung có thể thay đổi và tăng độ dài lúc chạy:
fn main() {
let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}");
}
Khi s ra khỏi scope (kết thúc main), Rust tự động gọi một hàm dọn dẹp đặc biệt tên drop để trả
lại vùng heap đó — không cần người viết code nhớ gọi gì cả, và việc này xảy ra ở đúng một chỗ xác
định trước (cuối scope), không phải đoán khi nào garbage collector rảnh để quét.
Move: gán giá trị không phải lúc nào cũng là copy
fn main() {
let s1 = String::from("hello");
let s2 = s1;
println!("{s1}, world!"); // lỗi: value borrowed here after move
}
Đoạn trên không biên dịch được. String gồm một con trỏ tới dữ liệu trên heap, độ dài, và dung
lượng đã cấp phát — ba giá trị này nằm trên stack. Khi gán s1 cho s2, Rust copy ba giá trị đó
(chứ không copy toàn bộ nội dung trên heap), nghĩa là sau dòng đó cả s1 và s2 đều có con trỏ trỏ
tới cùng một vùng heap.
Nếu để cả hai biến cùng hợp lệ, khi cả hai ra khỏi scope, Rust sẽ gọi drop hai lần trên cùng một
vùng heap — giải phóng hai lần (double free), một trong những lỗi bộ nhớ nghiêm trọng nhất. Để tránh
điều đó, Rust coi s1 không còn hợp lệ ngay sau khi gán cho s2 — đây gọi là một move:
quyền sở hữu chuyển từ s1 sang s2, không phải nhân đôi dữ liệu. Dùng s1 sau khi đã move là lỗi
biên dịch, bắt được ngay chứ không phải một bug ẩn chờ tới lúc chạy mới lộ ra.
Nếu thật sự cần cả hai biến cùng sở hữu hai bản dữ liệu độc lập trên heap, gọi .clone() tường minh
— chi phí sao chép sâu (deep copy) khi đó là chủ đích, nhìn vào code là thấy ngay, không bị giấu đi
trong một phép gán trông vô hại:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
}
Kiểu Copy: khi gán chỉ đơn thuần là copy
Các kiểu scalar đã gặp ở chương trước (số nguyên, số thực, bool, char) và tuple chỉ chứa toàn
kiểu Copy có kích thước cố định biết trước, nằm hoàn toàn trên stack — sao chép chúng chỉ là copy
vài byte, rẻ tới mức không cần phân biệt “move” hay “copy”. Những kiểu này implement trait Copy:
gán chúng cho biến khác không làm biến cũ mất hiệu lực:
fn main() {
let x = 5;
let y = x;
println!("x = {x}, y = {y}");
}
Một kiểu chỉ có thể implement Copy nếu bản thân nó và mọi thành phần bên trong đều không cần chạy
logic dọn dẹp gì đặc biệt lúc bị drop — String quản lý một vùng heap nên không thể là Copy.
Ownership và hàm
Truyền một giá trị vào hàm tuân theo đúng quy tắc move/copy như khi gán cho biến; trả một giá trị ra khỏi hàm cũng chuyển quyền sở hữu ra ngoài:
fn main() {
let s = String::from("hello");
lay_quyen_so_huu(s);
// s không còn hợp lệ ở đây — quyền sở hữu đã chuyển vào hàm
let x = 5;
khong_lay_quyen_so_huu(x);
println!("x vẫn dùng được vì i32 là Copy: {x}");
}
fn lay_quyen_so_huu(chuoi: String) {
println!("{chuoi}");
} // chuoi ra khỏi scope, drop vùng heap nó sở hữu
fn khong_lay_quyen_so_huu(so: i32) {
println!("{so}");
}
Luôn phải move quyền sở hữu vào rồi trả ngược ra chỉ để dùng tiếp một giá trị sẽ rất bất tiện — đó là lý do Rust có tham chiếu, nói ở phần tiếp theo, cho phép một hàm dùng một giá trị mà không cần nhận quyền sở hữu nó.
Tham chiếu và việc mượn
Phần trước kết thúc với một vấn đề: muốn một hàm chỉ dùng một giá trị (ví dụ đọc độ dài một
String) mà vẫn dùng tiếp được giá trị đó sau khi hàm chạy xong, sẽ phải move vào rồi trả ngược ra
— rất vướng víu nếu chỉ để đọc. Tham chiếu (reference) giải quyết đúng việc đó: cho một hàm truy
cập một giá trị mà không nhận quyền sở hữu nó.
Mượn bất biến
& tạo một tham chiếu — hành động này gọi là mượn (borrow): tham chiếu trỏ tới giá trị nhưng
không sở hữu nó, nên khi tham chiếu ra khỏi scope, giá trị gốc không hề bị drop.
fn main() {
let s1 = String::from("hello");
let len = do_dai(&s1);
println!("Độ dài của '{s1}' là {len}.");
}
fn do_dai(s: &String) -> usize {
s.len()
}
s1 vẫn dùng được sau lời gọi do_dai vì chỉ có một tham chiếu tới nó được truyền vào, không phải
bản thân giá trị. Giống let, tham chiếu mặc định cũng bất biến — không thể sửa giá trị thông qua
một tham chiếu thường:
fn main() {
let s = String::from("hello");
them_chu(&s);
}
fn them_chu(s: &String) {
s.push_str(", world"); // lỗi: `s` là tham chiếu bất biến
}
Mượn khả biến
Thêm mut ở cả nơi khai báo lẫn nơi tạo tham chiếu để sửa được giá trị qua tham chiếu:
fn main() {
let mut s = String::from("hello");
them_chu(&mut s);
println!("{s}");
}
fn them_chu(s: &mut String) {
s.push_str(", world");
}
Mượn khả biến đi kèm một ràng buộc quan trọng: tại một thời điểm, một giá trị chỉ có thể có một tham chiếu khả biến đang tồn tại. Đoạn sau không biên dịch được:
fn main() {
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{r1}, {r2}");
}
Ràng buộc này ngăn data race ngay tại thời điểm biên dịch thay vì để nó xảy ra lúc chạy rồi mới phát hiện (hoặc tệ hơn, không phát hiện được và cho ra kết quả sai ngẫu nhiên). Data race xảy ra khi từ hai trở lên con trỏ cùng truy cập một dữ liệu, ít nhất một trong số đó ghi, và không có cơ chế nào đồng bộ hoá truy cập — hậu quả kinh điển là dữ liệu đọc được nửa cũ nửa mới, hoặc bị hỏng hoàn toàn. Rust không đợi việc đó xảy ra rồi debug; nó cấm hẳn khả năng viết ra code như vậy.
Tương tự, không thể vừa có tham chiếu khả biến vừa có tham chiếu bất biến cùng lúc tới cùng một giá trị — một tham chiếu bất biến giả định giá trị không đổi trong suốt vòng đời của nó, trong khi tham chiếu khả biến có thể phá vỡ giả định đó bất cứ lúc nào:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
let r3 = &mut s;
println!("{r1}, {r2}, {r3}");
}
Ràng buộc này chỉ tính từ nơi một tham chiếu được tạo ra tới lần dùng cuối cùng của nó,
không phải tới hết scope theo nghĩa dấu ngoặc {} — nên đoạn sau vẫn hợp lệ, vì r1/r2 đã dùng
xong (trong lệnh println!) trước khi r3 xuất hiện:
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{r1} và {r2}");
let r3 = &mut s;
println!("{r3}");
}
Tham chiếu treo
Trình biên dịch Rust đảm bảo không bao giờ tồn tại một tham chiếu treo (dangling reference) — tham chiếu trỏ tới vùng nhớ đã bị giải phóng. Hàm sau cố trả về một tham chiếu tới biến cục bộ của chính nó:
#![allow(unused)]
fn main() {
fn tham_chieu_treo() -> &String {
let s = String::from("hello");
&s
}
}
s bị drop ngay khi tham_chieu_treo kết thúc, nên tham chiếu trả về sẽ trỏ tới vùng nhớ không còn
gì ở đó — trình biên dịch từ chối biên dịch đoạn này thay vì để nó chạy và đọc phải vùng nhớ đã giải
phóng. Cách sửa đúng là trả về chính giá trị String, chuyển quyền sở hữu ra ngoài thay vì chỉ
mượn:
#![allow(unused)]
fn main() {
fn tra_ve_gia_tri() -> String {
let s = String::from("hello");
s
}
}
Tóm lại, tại một thời điểm cho một giá trị, Rust chỉ chấp nhận một trong hai điều: hoặc nhiều tham chiếu bất biến, hoặc đúng một tham chiếu khả biến — không bao giờ cả hai cùng lúc. Toàn bộ việc kiểm tra này diễn ra lúc biên dịch (gọi là borrow checker), không có chi phí gì lúc chạy.
Slice
Xét bài toán: viết một hàm tìm từ đầu tiên trong một chuỗi, trả về vị trí kết thúc của từ đó. Cách
trực tiếp nhất là trả về một usize — chỉ số byte nơi từ đầu tiên kết thúc:
#![allow(unused)]
fn main() {
fn tu_dau_tien(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
}
Chỉ số trả về này chỉ có ý nghĩa khi đi kèm với String gốc — nhưng bản thân kiểu usize không hề
giữ mối liên kết đó. Nếu chuỗi gốc bị xoá hoặc thay nội dung sau khi có chỉ số, chỉ số cũ vẫn là một
con số hợp lệ nhưng không còn khớp với nội dung thật:
fn tu_dau_tien(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
fn main() {
let mut s = String::from("hello world");
let word = tu_dau_tien(&s);
s.clear();
// word vẫn là 5 ở đây, nhưng s giờ là chuỗi rỗng — 5 không còn ý nghĩa gì
println!("{word}");
}
Chương trình trên vẫn biên dịch và chạy — không có gì báo cho biết word đã trở nên vô nghĩa. Đây
đúng là dạng bug mà cơ chế mượn của Rust có thể bắt được, nếu hàm trả về đúng một thứ giữ liên kết
với dữ liệu gốc thay vì một con số rời rạc. Đó là việc slice làm được.
String slice
&str (string slice) là một tham chiếu tới một dải byte liên tiếp bên trong một String — không sở
hữu dữ liệu, chỉ tham chiếu tới nó, giống các tham chiếu đã gặp ở phần trước:
fn main() {
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
println!("{hello} {world}");
}
Viết lại hàm tìm từ đầu tiên để trả về một slice thay vì một chỉ số rời:
#![allow(unused)]
fn main() {
fn tu_dau_tien(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
}
Vì &str trả về là một tham chiếu tới s, borrow checker áp dụng đúng quy tắc đã học ở phần trước:
không thể có tham chiếu khả biến tới s (ví dụ gọi s.clear(), nhận &mut self) trong lúc tham
chiếu bất biến này còn sống. Thử biên dịch phiên bản dùng slice với cùng đoạn code gây lỗi ở trên sẽ
bắt được bug ngay tại thời điểm biên dịch:
fn main() {
let mut s = String::from("hello world");
let word = tu_dau_tien(&s);
s.clear(); // lỗi: không thể mượn khả biến `s` vì `word` đang mượn bất biến nó
println!("{word}");
}
fn tu_dau_tien(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
Đây chính là giá trị cốt lõi của slice: nó không chỉ là một cách biểu diễn dữ liệu tiện lợi, mà còn
kéo theo toàn bộ đảm bảo của hệ thống mượn — chỉ số cũ (usize) không thể trở nên “treo” theo nghĩa
logic vì bản thân nó không phải tham chiếu, nhưng slice thì có, và nhờ vậy trình biên dịch phát hiện
được chính xác loại lỗi vừa mô tả.
String literal (let s = "hello";) vốn dĩ đã có kiểu &str — một slice trỏ thẳng vào dữ liệu nằm
sẵn trong binary đã biên dịch, bất biến theo đúng bản chất của binary. Đây cũng là lý do các hàm nên
nhận tham số kiểu &str thay vì &String khi có thể: một &String tự động ép kiểu được về &str,
nên nhận &str cho phép hàm nhận cả hai, tổng quát hơn mà không mất gì.
Slice trên các collection khác
Khái niệm slice không riêng gì String — áp dụng cho mọi dãy dữ liệu liên tiếp, ví dụ mảng:
fn main() {
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
println!("{:?}", slice);
assert_eq!(slice, &[2, 3]);
}
&[i32] mang theo cả con trỏ đầu dải lẫn độ dài, giống hệt cách &str hoạt động với String —
cùng một cơ chế, áp dụng cho mọi kiểu dữ liệu là một dãy liên tiếp trong bộ nhớ.
Struct
struct gom nhiều giá trị liên quan thành một kiểu có tên, giống tuple ở chương trước nhưng mỗi
phần tử có tên field riêng — không cần nhớ thứ tự để biết .0 là gì, .1 là gì.
Định nghĩa và khởi tạo struct
struct NguoiDung {
ten_dang_nhap: String,
email: String,
so_lan_dang_nhap: u64,
dang_hoat_dong: bool,
}
fn main() {
let user1 = NguoiDung {
ten_dang_nhap: String::from("tuanem"),
email: String::from("tuanem@example.com"),
so_lan_dang_nhap: 1,
dang_hoat_dong: true,
};
println!("{}", user1.email);
}
Field của một instance đã tạo được truy cập bằng dấu chấm (user1.email). Nếu instance được khai
báo mut, có thể gán lại field bằng chính cú pháp đó — lưu ý tính khả biến áp dụng cho toàn bộ
instance, Rust không cho khai báo chỉ một field là mut trong khi các field khác bất biến.
Khi tên tham số trùng tên field, có thể viết gọn (field init shorthand) thay vì lặp lại
field: field:
struct NguoiDung {
ten_dang_nhap: String,
email: String,
so_lan_dang_nhap: u64,
dang_hoat_dong: bool,
}
fn tao_nguoi_dung(ten_dang_nhap: String, email: String) -> NguoiDung {
NguoiDung {
ten_dang_nhap,
email,
so_lan_dang_nhap: 1,
dang_hoat_dong: true,
}
}
fn main() {
let user1 = tao_nguoi_dung(String::from("tuanem"), String::from("tuanem@example.com"));
println!("{}", user1.ten_dang_nhap);
}
Struct update syntax
Tạo một instance mới phần lớn giống một instance đã có, chỉ khác vài field, dùng ..instance_cu ở
cuối để lấy các field còn lại từ instance đó:
struct NguoiDung {
ten_dang_nhap: String,
email: String,
so_lan_dang_nhap: u64,
dang_hoat_dong: bool,
}
fn main() {
let user1 = NguoiDung {
ten_dang_nhap: String::from("tuanem"),
email: String::from("tuanem@example.com"),
so_lan_dang_nhap: 1,
dang_hoat_dong: true,
};
let user2 = NguoiDung {
email: String::from("khac@example.com"),
..user1
};
println!("{}", user2.ten_dang_nhap);
}
Cú pháp này chỉ là gán field, nên tuân theo đúng quy tắc move/copy đã học ở chương Quyền sở hữu:
ten_dang_nhap là String (không phải Copy), nên field đó bị move từ user1 sang user2 —
user1 vẫn dùng được các field khác, nhưng user1.ten_dang_nhap thì không, vì phần đó đã chuyển
quyền sở hữu sang user2.
Tuple struct
Khi tên field sẽ chỉ là thừa thãi (thứ tự đã đủ nói lên ý nghĩa), dùng tuple struct — struct có tên kiểu nhưng field không tên, truy cập bằng chỉ số như tuple:
struct Diem(i32, i32, i32);
fn main() {
let goc = Diem(0, 0, 0);
println!("{} {} {}", goc.0, goc.1, goc.2);
}
Diem(i32, i32, i32) và một tuple (i32, i32, i32) trần không thể dùng thay cho nhau dù cấu trúc
dữ liệu bên dưới giống hệt — struct Diem là một kiểu riêng biệt, một hàm khai báo nhận Diem sẽ
từ chối một tuple (i32, i32, i32) thường, giúp trình biên dịch phân biệt hai khái niệm dù về mặt
bit là như nhau.
Unit-like struct
Một struct không có field nào cả:
struct DanhDauDaXuLy;
fn main() {
let _danh_dau = DanhDauDaXuLy;
}
Hữu ích khi cần một kiểu để implement một trait (chương sau) nhưng bản thân kiểu đó không cần lưu dữ liệu gì — bản thân sự tồn tại của instance đã mang đủ ý nghĩa.
In một struct để debug
Chú thích #[derive(Debug)] phía trên struct để có thể in toàn bộ instance bằng specifier {:?}
(hoặc {:#?} để in xuống dòng, dễ đọc hơn với struct nhiều field) — hữu ích khi debug, không dùng
{} vì {} yêu cầu kiểu implement Display, trait dành cho định dạng hiển thị cho người dùng cuối
chứ không phải để debug:
#[derive(Debug)]
struct HinhChuNhat {
rong: u32,
cao: u32,
}
fn main() {
let hcn = HinhChuNhat {
rong: 30,
cao: 50,
};
println!("{hcn:?}");
println!("{hcn:#?}");
}
Method
Method là một hàm gắn với một kiểu cụ thể, khai báo bên trong khối impl của kiểu đó, tham số
đầu tiên luôn là một dạng của self — chỉ chính instance đang gọi method.
#[derive(Debug)]
struct HinhChuNhat {
rong: u32,
cao: u32,
}
impl HinhChuNhat {
fn dien_tich(&self) -> u32 {
self.rong * self.cao
}
}
fn main() {
let hcn = HinhChuNhat { rong: 30, cao: 50 };
println!("Diện tích: {}", hcn.dien_tich());
}
&self là viết tắt của self: &Self (Self là bí danh cho chính kiểu impl đang khai báo) — mượn
bất biến instance, đúng nhu cầu của dien_tich vì nó chỉ đọc, không sửa. Method cần sửa dữ liệu
nhận &mut self; method cần lấy quyền sở hữu instance (hiếm gặp, thường chỉ khi method biến
instance thành một kiểu khác rồi trả về) nhận self không tham chiếu.
Tự động tham chiếu / giải tham chiếu
Gọi hcn.dien_tich() ở trên hoạt động dù dien_tich khai báo nhận &self chứ không phải nhận
thẳng HinhChuNhat — Rust tự thêm &, &mut, hoặc * khi cần để khớp chữ ký method, không cần
người gọi tự viết (&hcn).dien_tich(). Đây gọi là tự động tham chiếu (automatic referencing):
khi thấy obj.method(), Rust nhìn chữ ký method rồi tự quyết định obj, &obj, hay &mut obj
là thứ cần truyền, dựa vào đó mà không cần cú pháp con trỏ tường minh như một số ngôn ngữ hệ thống
khác (ví dụ C, nơi phải tự viết rõ -> hay . tuỳ đang thao tác trên con trỏ hay giá trị).
Nhiều tham số
Method nhận thêm tham số bình thường sau self:
struct HinhChuNhat {
rong: u32,
cao: u32,
}
impl HinhChuNhat {
fn dien_tich(&self) -> u32 {
self.rong * self.cao
}
fn co_the_chua(&self, cai_khac: &HinhChuNhat) -> bool {
self.rong > cai_khac.rong && self.cao > cai_khac.cao
}
}
fn main() {
let hcn1 = HinhChuNhat { rong: 30, cao: 50 };
let hcn2 = HinhChuNhat { rong: 10, cao: 40 };
println!("hcn1 chứa được hcn2: {}", hcn1.co_the_chua(&hcn2));
}
Hàm liên kết (không nhận self)
Một hàm khai báo trong impl nhưng không nhận tham số self nào gọi là hàm liên kết
(associated function) — không gắn với một instance cụ thể, gọi qua cú pháp Kiểu::ten_ham thay vì
instance.ten_ham(). String::from chính là một hàm liên kết. Dùng phổ biến nhất: viết một hàm
dựng sẵn đóng vai trò khởi tạo, tương tự “constructor” ở một số ngôn ngữ khác nhưng thực chất chỉ là
một hàm liên kết bình thường, không phải cú pháp đặc biệt riêng cho việc khởi tạo:
struct HinhChuNhat {
rong: u32,
cao: u32,
}
impl HinhChuNhat {
fn hinh_vuong(canh: u32) -> Self {
Self {
rong: canh,
cao: canh,
}
}
}
fn main() {
let hv = HinhChuNhat::hinh_vuong(20);
println!("{} {}", hv.rong, hv.cao);
}
Một kiểu có thể có nhiều khối impl riêng biệt — hợp lệ về cú pháp dù không có lý do đặc biệt để
tách trong ví dụ đơn giản này, nhưng hữu ích khi tổ chức code theo nhóm (ví dụ tách riêng phần
implement một trait, sẽ gặp ở chương sau) khỏi phần method thường của kiểu.
Enum và khớp mẫu
Struct gom nhiều giá trị liên quan lại thành một; enum diễn tả điều ngược lại — một giá trị chỉ có thể là một trong số các khả năng đã liệt kê, không phải cả một tập giá trị cùng lúc.
Định nghĩa enum
enum DiaChiIp {
V4,
V6,
}
fn main() {
let phien_ban_bon = DiaChiIp::V4;
let phien_ban_sau = DiaChiIp::V6;
}
Một địa chỉ IP chỉ có thể là bản 4 hoặc bản 6, không bao giờ là cả hai — enum diễn tả đúng ràng buộc “loại trừ lẫn nhau” đó ngay trong hệ thống kiểu, thay vì phải tự quản lý bằng một cờ boolean hay một số nguyên tuỳ ý và hy vọng không dùng sai.
Mỗi biến thể (variant) có thể mang theo dữ liệu riêng, kể cả khác kiểu nhau giữa các variant — điều struct không làm được vì một struct chỉ có một tập field cố định:
enum DiaChiIp {
V4(u8, u8, u8, u8),
V6(String),
}
fn main() {
let nha = DiaChiIp::V4(127, 0, 0, 1);
let vong_lap = DiaChiIp::V6(String::from("::1"));
}
Dữ liệu đi thẳng vào variant, không cần một struct riêng rồi bọc enum bên ngoài trỏ tới struct đó. Variant còn có thể mang dữ liệu có cấu trúc như một struct thu nhỏ (named field) hoặc như tuple:
enum ThongDiep {
Thoat,
DiChuyen { x: i32, y: i32 },
Ghi(String),
DoiMau(i32, i32, i32),
}
fn main() {
let cac_thong_diep = [
ThongDiep::Thoat,
ThongDiep::DiChuyen { x: 10, y: 20 },
ThongDiep::Ghi(String::from("xin chào")),
ThongDiep::DoiMau(255, 0, 0),
];
}
Bốn variant này, nếu viết bằng struct, sẽ là bốn kiểu struct hoàn toàn khác nhau (Thoat,
DiChuyen, Ghi, DoiMau) — enum gói chúng vào chung một kiểu ThongDiep, cho phép viết một hàm
nhận tham số kiểu ThongDiep xử lý được cả bốn trường hợp, thay vì phải viết bốn hàm hoặc dùng
trait object.
Giống struct, enum cũng nhận method qua impl:
enum ThongDiep {
Thoat,
Ghi(String),
}
impl ThongDiep {
fn mo_ta(&self) -> &str {
match self {
ThongDiep::Thoat => "thoát",
ThongDiep::Ghi(_) => "ghi dữ liệu",
}
}
}
fn main() {
let td = ThongDiep::Ghi(String::from("log"));
println!("{}", td.mo_ta());
}
(match dùng ở đây sẽ được giải thích kỹ ở phần tiếp theo.)
Option: thay thế cho null
Rust không có null. Thay vào đó, thư viện chuẩn định nghĩa enum Option<T> để diễn tả “có giá trị
hoặc không”:
#![allow(unused)]
fn main() {
enum Option<T> {
None,
Some(T),
}
}
(Option<T> đã có sẵn trong prelude, không cần khai báo lại hay đưa Option:: vào scope tường minh
— dùng thẳng Some/None.)
Vấn đề null từng gây ra vô số lỗi runtime kinh điển: bất kỳ giá trị nào cũng có thể ngầm là
null, và trình biên dịch không bắt buộc kiểm tra trước khi dùng — lỗi chỉ lộ ra lúc chạy, đúng chỗ
code cố dùng giá trị không tồn tại. Option<T> sửa vấn đề đó bằng cách tách hẳn “có giá trị kiểu
T” (Some(T)) khỏi “không có giá trị” (None) thành hai biến thể của cùng một kiểu — một T
trần và một Option<T> là hai kiểu khác nhau, nên trình biên dịch từ chối biên dịch nếu dùng
Option<T> như thể chắc chắn có giá trị mà chưa xử lý trường hợp None:
fn main() {
let so: Option<i8> = Some(5);
let x: i8 = 5;
let tong = x + so; // lỗi: không thể cộng i8 với Option<i8>
}
Muốn lấy giá trị T bên trong một Option<T>, phải xử lý tường minh cả hai khả năng — thường bằng
match (phần tiếp theo) hoặc các method như unwrap_or, map. Chi phí gõ thêm vài dòng xử lý đổi
lấy một đảm bảo chắc chắn: không thể quên kiểm tra “giá trị có tồn tại không” ở một chỗ nào đó rồi
để lỗi lộ ra lúc chạy — trình biên dịch không cho qua nếu thiếu.
match
match so khớp một giá trị lần lượt với từng pattern, chạy code ở nhánh khớp đầu tiên. Khác
if, điều kiện không cần là bool — pattern có thể là một biến thể enum, một giá trị literal, một
khoảng, hay một cấu trúc để destructure dữ liệu ra ngay trong lúc so khớp.
enum DongTien {
MotXu,
NamXu,
MuoiXu,
HaiNamXu,
}
fn gia_tri_xu(dong_tien: DongTien) -> u8 {
match dong_tien {
DongTien::MotXu => 1,
DongTien::NamXu => 5,
DongTien::MuoiXu => 10,
DongTien::HaiNamXu => 25,
}
}
fn main() {
println!("{}", gia_tri_xu(DongTien::MuoiXu));
}
Mỗi nhánh là một cặp pattern => expression. Giống if/else, match cũng là một expression —
giá trị của nhánh khớp chính là giá trị của cả match, nên gia_tri_xu không cần return.
Destructure ngay trong pattern
Nếu variant mang dữ liệu, pattern có thể lấy luôn dữ liệu đó ra thành biến dùng được trong nhánh:
enum ThongDiep {
DiChuyen { x: i32, y: i32 },
Ghi(String),
}
fn xu_ly(td: ThongDiep) {
match td {
ThongDiep::DiChuyen { x, y } => {
println!("Di chuyển tới ({x}, {y})");
}
ThongDiep::Ghi(noi_dung) => {
println!("Ghi: {noi_dung}");
}
}
}
fn main() {
xu_ly(ThongDiep::DiChuyen { x: 1, y: 2 });
xu_ly(ThongDiep::Ghi(String::from("log")));
}
match với Option
Xử lý Option<T> bằng match là cách thành ngữ để lấy giá trị T ra một cách an toàn:
fn cong_mot(x: Option<i32>) -> Option<i32> {
match x {
None => None,
Some(i) => Some(i + 1),
}
}
fn main() {
let nam = Some(5);
let sau = cong_mot(nam);
let khong_gi = cong_mot(None);
println!("{sau:?} {khong_gi:?}");
}
match bắt buộc đầy đủ (exhaustive)
Trình biên dịch buộc một match phải liệt kê hết mọi khả năng của kiểu đang so khớp — thiếu một
biến thể là lỗi biên dịch, không phải bug ẩn tới lúc chạy mới lộ ra khi rơi vào đúng nhánh bị bỏ
sót:
#![allow(unused)]
fn main() {
fn cong_mot(x: Option<i32>) -> Option<i32> {
match x {
Some(i) => Some(i + 1),
// thiếu nhánh None — lỗi: non-exhaustive patterns
}
}
}
Đây là lý do match (chứ không phải chỉ if) là công cụ chính để xử lý enum trong Rust: mỗi lần
thêm một variant mới vào enum ở đâu đó trong codebase, mọi match cũ chưa xử lý variant đó sẽ báo
lỗi biên dịch ngay, chỉ thẳng những chỗ cần cập nhật — thay vì im lặng bỏ qua trường hợp mới.
Khi không cần liệt kê hết, dùng _ để bắt mọi giá trị còn lại (hoặc một tên biến để vừa bắt vừa
dùng được giá trị đó):
fn mo_ta_xu(gia_tri: u8) -> &'static str {
match gia_tri {
1 => "một xu",
5 => "năm xu",
10 => "mười xu",
khac => {
println!("Không có mệnh giá {khac} xu tiêu chuẩn");
"không xác định"
}
}
}
fn main() {
println!("{}", mo_ta_xu(10));
println!("{}", mo_ta_xu(2));
}
Nếu không cần dùng tới giá trị bắt được, thay khac bằng _ để trình biên dịch không cảnh báo biến
không dùng tới.
if let và let else
match đầy đủ nhưng dài dòng khi chỉ thật sự quan tâm một pattern và muốn bỏ qua mọi trường hợp
khác. if let viết gọn đúng tình huống đó:
fn main() {
let so_toi_da: Option<u8> = Some(3);
if let Some(max) = so_toi_da {
println!("Số tối đa: {max}");
}
}
Đoạn trên tương đương một match với nhánh Some(max) => println!(...) và nhánh _ => () bị bỏ
qua — nhưng đổi lại việc rút gọn này, if let mất đi kiểm tra đầy đủ mà match đảm bảo: thêm
một variant mới vào enum sẽ không khiến if let báo lỗi biên dịch, vì bản chất nó chưa từng buộc
phải xử lý hết mọi khả năng. Chọn if let là đánh đổi có ý thức giữa ngắn gọn và đầy đủ, không phải
lựa chọn mặc định.
Kèm else để xử lý trường hợp không khớp, tương đương nhánh _ của match:
enum DongTien {
HaiNamXu(String), // tên bang được in trên đồng xu
Khac,
}
fn main() {
let xu = DongTien::HaiNamXu(String::from("Texas"));
let mut dem = 0;
if let DongTien::HaiNamXu(bang) = xu {
println!("Đồng 25 xu từ bang {bang}!");
} else {
dem += 1;
}
println!("{dem}");
}
let-else
Một tình huống rất thường gặp: muốn lấy giá trị ra khỏi một pattern, và nếu không khớp thì thoát
sớm khỏi hàm hiện tại (return, break, hoặc panic!) — không phải xử lý gì thêm trong nhánh
khớp mà tiếp tục dùng biến đó ở phần code phía sau, cùng cấp với if let chứ không bị lồng vào
trong:
fn mo_ta(so: Option<u8>) -> String {
let Some(gia_tri) = so else {
return String::from("không có giá trị");
};
format!("giá trị là {gia_tri}")
}
fn main() {
println!("{}", mo_ta(Some(5)));
println!("{}", mo_ta(None));
}
Nếu viết bằng if let thông thường, phần code dùng gia_tri sẽ phải nằm lồng trong khối if let
(vì gia_tri chỉ tồn tại trong scope của nhánh khớp), khiến hàm dài thêm một cấp thụt lề dù logic
chính chỉ có một luồng. let-else đảo ngược cấu trúc: nhánh không khớp (else) mới là nhánh
bắt buộc phải rẽ nhánh khỏi luồng chính (return/break/panic!/continue — bất kỳ thứ gì khiến
hàm không “rơi xuống” tiếp), còn biến bind được ở nhánh khớp (gia_tri) tồn tại ngay trong scope
bao quanh, dùng tiếp bình thường mà không cần lồng thêm khối nào.
Tổ chức dự án với package, crate, module
Khi một dự án lớn dần, gom hết code vào một file trở nên khó quản lý. Rust có ba khái niệm tổ chức code lồng nhau, từ lớn tới nhỏ: package, crate, và module. Chương này chỉ nói cách tổ chức code trong cùng một dự án — chia sẻ code giữa các dự án khác nhau (đăng package lên crates.io) sẽ nói ở chương Cargo nâng cao.
Crate
Crate là đơn vị biên dịch nhỏ nhất mà trình biên dịch Rust xử lý trong một lần gọi rustc — có
hai dạng:
- Binary crate: có hàm
main, biên dịch ra một file thực thi được. - Library crate: không có
main, chứa chức năng để crate khác dùng lại (đây cũng là nghĩa thường gặp của từ “crate” khi nói tới một thư viện Rust công khai, ví dụ trên crates.io).
Trình biên dịch bắt đầu đọc một crate từ một file gọi là crate root — với binary crate là
src/main.rs, với library crate là src/lib.rs. Nội dung của file gốc này tạo thành module có tên
crate, đứng đầu toàn bộ cây module của crate đó (nói kỹ ở phần sau).
Package
Package là một hay nhiều crate cùng cung cấp một nhóm chức năng, mô tả bởi file Cargo.toml ở
thư mục gốc — khai báo tên, phiên bản, dependency. Một package chứa tối đa một library crate,
nhưng có thể chứa nhiều binary crate:
src/main.rs(nếu có) → một binary crate cùng tên với package.src/lib.rs(nếu có) → library crate cùng tên với package.- Mỗi file trong
src/bin/→ thêm một binary crate riêng.
cargo new ten_du_an tạo một package mới với Cargo.toml và src/main.rs — một package chỉ có
đúng một binary crate, cấu trúc phổ biến nhất cho một chương trình độc lập.
Module và phạm vi truy cập
mod gom các item liên quan (hàm, struct, enum, module con…) vào một khối có tên, tạo thành một
cây module — module gốc của crate root nằm ở đỉnh cây, tên là crate.
mod thu_vien {
pub mod muon_tra {
pub fn muon_sach() {
println!("Đã mượn sách");
}
pub fn tra_sach() {
super::sap_xep_lai();
}
}
fn sap_xep_lai() {
println!("Sắp xếp lại kệ sách");
}
}
fn main() {
crate::thu_vien::muon_tra::muon_sach();
crate::thu_vien::muon_tra::tra_sach();
}
Quy tắc riêng tư
Mọi item — hàm, struct, module con — mặc định riêng tư (private), chỉ nhìn thấy được từ chính
module khai báo nó và các module con cháu của module đó. Muốn một module hay item bên ngoài nhìn
thấy được, phải khai báo pub tường minh. Đây là lựa chọn mặc định ngược với “mọi thứ đều công khai
trừ khi giấu đi” — buộc người viết code phải chủ động quyết định phần nào là API công khai, thay vì
lộ ra ngoài mọi chi tiết cài đặt bên trong theo mặc định.
Quy tắc nhìn thấy đi theo một chiều duy nhất: module con nhìn thấy được item riêng tư của module
cha (và mọi tổ tiên của nó), nhưng module cha không tự nhìn thấy được item riêng tư của con
trừ khi con khai báo pub. Ở ví dụ trên, sap_xep_lai là hàm riêng tư của thu_vien, nhưng gọi
được từ muon_tra (module con của thu_vien) — ngược lại thì không:
mod thu_vien {
pub mod muon_tra {
fn ghi_log() {}
}
fn kiem_tra() {
muon_tra::ghi_log(); // lỗi: `ghi_log` là hàm riêng tư của module `muon_tra`
}
}
fn main() {}
Trực giác: một module con biết rõ ngữ cảnh nội bộ của cha (nó được định nghĩa bên trong cha), nên được tin tưởng truy cập chi tiết cài đặt của cha; nhưng cha không nên phải biết chi tiết cài đặt bên trong của từng con — đó chính là điều giúp con được tự do thay đổi cài đặt nội bộ mà không ảnh hưởng tới cha, miễn giữ nguyên phần đã công khai.
pub trên struct và enum
pub trên một struct chỉ công khai chính kiểu đó — từng field vẫn riêng tư theo mặc định,
phải đánh dấu pub cho từng field muốn lộ ra ngoài:
mod bep {
pub struct BuaAn {
pub mon_chinh: String,
gia_tien: f64, // vẫn riêng tư dù struct đã pub
}
impl BuaAn {
pub fn moi(mon_chinh: &str, gia_tien: f64) -> BuaAn {
BuaAn {
mon_chinh: String::from(mon_chinh),
gia_tien,
}
}
}
}
fn main() {
let bua = bep::BuaAn::moi("Phở", 45.0);
println!("{}", bua.mon_chinh); // được, vì field này pub
}
enum thì ngược lại: một enum đã pub thì mọi variant của nó tự động công khai theo — nếu
phải khai báo pub cho từng variant sẽ rất rườm rà, vì gần như luôn cần dùng được hết các variant
mới coi enum đó có ích.
Đường dẫn, use, và tách file
Đường dẫn tuyệt đối và tương đối
Gọi một item qua cây module cần một đường dẫn, theo một trong hai dạng: tuyệt đối, bắt đầu từ
gốc crate bằng từ khoá crate, hoặc tương đối, bắt đầu từ module hiện tại (self, hoặc thẳng
tên item nếu cùng module) — dùng super để đi lên module cha, tương tự .. trong đường dẫn thư
mục:
mod nha_bep {
pub fn nau_an() {
sua_soan_nguyen_lieu();
}
fn sua_soan_nguyen_lieu() {
println!("Đang sơ chế");
}
pub mod trang_tri {
pub fn xep_dia() {
super::sua_soan_nguyen_lieu(); // super trỏ về nha_bep, module cha
}
}
}
fn phuc_vu() {
crate::nha_bep::nau_an(); // đường dẫn tuyệt đối, từ gốc crate
nha_bep::nau_an(); // đường dẫn tương đối, vì phuc_vu ở cùng cấp với nha_bep
nha_bep::trang_tri::xep_dia();
}
fn main() {
phuc_vu();
}
Chọn tuyệt đối hay tương đối phần lớn là vấn đề sở thích — tương đối tiện khi hay di chuyển cả một
khối module đi nơi khác trong cây (các đường dẫn super/tên module con vẫn đúng), tuyệt đối rõ ràng
hơn khi đường dẫn dài hoặc code hay bị cắt dán sang chỗ khác.
use: đưa đường dẫn vào scope
Gõ lại đường dẫn đầy đủ mỗi lần dùng một item rất dài dòng. use đưa một đường dẫn vào scope hiện
tại để dùng thẳng tên ngắn:
mod nha_hang {
pub mod nha_bep {
pub fn nau_an() {
println!("Đang nấu");
}
}
}
use crate::nha_hang::nha_bep;
fn main() {
nha_bep::nau_an();
}
Quy ước thành ngữ: với hàm, use chỉ nên đưa module chứa hàm vào scope (như ví dụ trên) rồi
gọi qua module::ham() — nhìn vào lời gọi là biết ngay hàm không phải định nghĩa cục bộ. Với
struct, enum, trait, use nên đưa thẳng đường dẫn đầy đủ vào scope, vì phần lớn thời gian
sẽ nhắc tới tên kiểu đó nhiều lần và không có nhập nhằng nào cần làm rõ:
use std::collections::HashMap;
fn main() {
let mut diem: HashMap<String, i32> = HashMap::new();
diem.insert(String::from("An"), 10);
}
Khi hai kiểu trùng tên tới từ hai module khác nhau, đưa thẳng cả hai vào scope sẽ xung đột — hoặc
chỉ đưa module cha vào scope của từng cái (theo quy ước dùng cho hàm), hoặc dùng as để đặt bí
danh:
#![allow(unused)]
fn main() {
use std::fmt;
use std::io;
fn viet(_ket_qua: fmt::Result) {}
fn doc(_ket_qua: io::Result<()>) {}
}
#![allow(unused)]
fn main() {
use std::fmt::Result as FmtResult;
use std::io::Result as IoResult;
fn viet(_ket_qua: FmtResult) {}
fn doc(_ket_qua: IoResult<()>) {}
}
pub use: re-export
use thường thì chỉ đưa tên vào scope nội bộ; thêm pub phía trước để code ở ngoài crate cũng
gọi được qua đường dẫn mới này, dù item gốc nằm sâu trong một module riêng tư của việc tổ chức nội
bộ:
mod nha_bep {
pub fn nau_an() {
println!("Đang nấu");
}
}
pub use crate::nha_bep::nau_an;
fn main() {
nau_an(); // gọi thẳng nhờ re-export, không cần đường dẫn qua nha_bep
}
pub use tách rời cấu trúc thư mục/module nội bộ khỏi API nhìn thấy được từ bên ngoài — có
thể tổ chức code nội bộ theo cách hợp lý với người viết, đồng thời export ra một cấu trúc phẳng, dễ
dùng hơn cho người gọi crate từ bên ngoài.
Gộp nhiều use
Nhiều use cùng chung tiền tố gộp được bằng cú pháp lồng {}, hoặc dùng * để đưa mọi item
công khai của một module vào scope (thường chỉ hợp lý trong code test, ít dùng ở code thường vì làm
mất khả năng biết một tên tới từ đâu):
use std::{cmp::Ordering, io};
fn main() {
let ket_qua = 1.cmp(&2);
match ket_qua {
Ordering::Less => println!("nhỏ hơn"),
Ordering::Equal => println!("bằng"),
Ordering::Greater => println!("lớn hơn"),
}
let _ = io::stdin();
}
Tách module ra file riêng
Khi một module đủ lớn, tách nó khỏi file cha bằng cách khai báo mod ten_module; — dấu ; thay cho
khối {} báo cho trình biên dịch: nội dung module nằm ở một file khác, tự đi tìm theo tên:
// src/lib.rs
mod nha_bep;
Trình biên dịch tìm nội dung ở src/nha_bep.rs. Nếu nha_bep còn chứa module con cũng cần tách
riêng (ví dụ trang_tri), chúng nằm trong một thư mục cùng tên module cha:
src/
├── lib.rs
├── nha_bep.rs
└── nha_bep/
└── trang_tri.rs
Trong src/nha_bep.rs, khai báo pub mod trang_tri; tương tự — trình biên dịch tiếp tục đi tìm ở
src/nha_bep/trang_tri.rs. Cây module (đường dẫn, quy tắc riêng tư) không hề thay đổi khi tách file
— tách file chỉ là chuyện tổ chức mã nguồn vật lý trên đĩa, hoàn toàn độc lập với cấu trúc module
logic đã khai báo bằng mod.
Collection phổ biến
Khác mảng và tuple (kích thước cố định, biết trước lúc biên dịch), các collection trong chương này
lưu dữ liệu trên heap — số lượng phần tử tăng giảm được lúc chạy. Ba collection dùng nhiều nhất:
Vec<T> (danh sách), String (chuỗi UTF-8), và HashMap<K, V> (bảng băm khoá-giá trị).
Vector
Vec<T> lưu nhiều giá trị cùng kiểu T, liền nhau trong bộ nhớ, độ dài thay đổi được:
fn main() {
let mut v: Vec<i32> = Vec::new();
v.push(5);
v.push(6);
v.push(7);
println!("{v:?}");
}
Chú thích kiểu Vec<i32> cần thiết ở Vec::new() vì chưa có phần tử nào để trình biên dịch suy ra
T. Có sẵn giá trị ngay từ đầu, dùng macro vec! — trình biên dịch suy luận được T từ chính giá
trị truyền vào:
fn main() {
let v = vec![1, 2, 3];
println!("{v:?}");
}
Đọc phần tử
Hai cách đọc một phần tử theo chỉ số, khác nhau ở cách xử lý chỉ số vượt quá độ dài:
fn main() {
let v = vec![1, 2, 3, 4, 5];
let thu_ba: &i32 = &v[2];
println!("Phần tử thứ ba: {thu_ba}");
let thu_ba: Option<&i32> = v.get(2);
match thu_ba {
Some(gia_tri) => println!("Phần tử thứ ba: {gia_tri}"),
None => println!("Không có phần tử thứ ba"),
}
}
v[2] panic ngay nếu chỉ số vượt quá độ dài — hợp lý khi truy cập ngoài phạm vi là một lỗi
logic nghiêm trọng, chương trình nên dừng lại thay vì chạy tiếp với dữ liệu sai. v.get(2) trả về
Option<&i32> — None khi vượt quá độ dài thay vì panic, hợp lý khi việc “không có phần tử ở vị
trí đó” là một khả năng bình thường cần xử lý, ví dụ chỉ số tới từ input người dùng.
Vòng đời tham chiếu và việc mutate
Borrow checker áp dụng đúng các quy tắc đã học ở chương Quyền sở hữu cho phần tử bên trong Vec:
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let phan_tu_dau = &v[0];
v.push(6);
println!("Phần tử đầu: {phan_tu_dau}");
}
Lỗi này thoạt nhìn khó hiểu — thêm một phần tử ở cuối sao lại ảnh hưởng tới tham chiếu ở đầu? Bản
chất: Vec lưu phần tử liền nhau trong một vùng cấp phát; nếu vùng đó hết chỗ, push phải cấp phát
một vùng mới lớn hơn rồi copy toàn bộ phần tử cũ sang, giải phóng vùng cũ. Nếu điều đó xảy ra,
phan_tu_dau sẽ trỏ vào vùng nhớ đã giải phóng — một tham chiếu treo. Borrow checker ngăn khả năng
đó bằng đúng quy tắc quen thuộc: không thể có tham chiếu bất biến (phan_tu_dau) và tham chiếu khả
biến (v.push cần mượn khả biến v) cùng lúc.
Lưu nhiều kiểu khác nhau bằng enum
Một Vec chỉ chứa được đúng một kiểu T — nhưng T có thể là một enum, và enum thì gom được nhiều
“loại” dữ liệu khác nhau vào chung một kiểu (đã học ở chương Enum):
enum OGiaTri {
So(i32),
Chu(String),
}
fn main() {
let hang: Vec<OGiaTri> = vec![
OGiaTri::So(3),
OGiaTri::Chu(String::from("xin chào")),
OGiaTri::So(10),
];
for o in &hang {
match o {
OGiaTri::So(n) => println!("số: {n}"),
OGiaTri::Chu(s) => println!("chữ: {s}"),
}
}
}
Cách này đòi hỏi biết trước toàn bộ tập kiểu có thể xuất hiện tại thời điểm biên dịch. Khi tập kiểu không biết trước (ví dụ cho phép plugin bên ngoài định nghĩa thêm), cần tới trait object — nói ở chương Trait object và hướng đối tượng.
String
String thực chất là một wrapper mỏng quanh Vec<u8>, kèm đảm bảo nội dung bên trong luôn là UTF-8
hợp lệ — mọi thứ đã học về Vec ở phần trước (cấp phát trên heap, độ dài thay đổi được, quy tắc
mượn khi mutate) đều áp dụng cho String.
Tạo và cập nhật
fn main() {
let mut s = String::new();
let du_lieu = "nội dung ban đầu";
let s2 = du_lieu.to_string();
let s3 = String::from("nội dung ban đầu");
s.push_str("xin chào");
s.push(' ');
s.push_str("thế giới");
println!("{s} | {s2} | {s3}");
}
push_str nhận &str (mượn, không lấy quyền sở hữu chuỗi truyền vào), push nhận đúng một char.
Nối chuỗi bằng + gọi thẳng tới method add(self, s: &str) -> String — nhận self theo giá trị
(không phải tham chiếu), nên lấy quyền sở hữu toán hạng bên trái:
fn main() {
let s1 = String::from("Xin chào, ");
let s2 = String::from("thế giới!");
let s3 = s1 + &s2;
// s1 đã bị move vào add, không dùng lại được; s2 vẫn dùng được vì chỉ mượn (&s2)
println!("{s3}");
}
Nối nhiều chuỗi bằng + liên tiếp nhanh chóng khó đọc; macro format! giải quyết gọn hơn và
không lấy quyền sở hữu bất kỳ tham số nào (giống println! nhưng trả về String thay vì in
ra):
fn main() {
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
println!("{s} (vẫn dùng được s1: {s1})");
}
Vì sao không đánh chỉ số một String bằng số nguyên
fn main() {
let s = String::from("chào");
let ky_tu_dau = s[0]; // lỗi: String không implement Index<{integer}>
}
Đây không phải giới hạn tuỳ tiện. String được lưu dưới dạng byte UTF-8 — ký tự ASCII (không dấu)
chiếm đúng 1 byte, nhưng hầu hết ký tự có dấu tiếng Việt chiếm 2 đến 3 byte. Chữ “chào” gồm bốn
ký tự nhìn thấy nhưng nhiều hơn bốn byte thật sự trong bộ nhớ, vì riêng “à” đã chiếm 2 byte — chỉ số
byte 0 và chỉ số ký tự thứ nhất không còn khớp nhau kể từ ký tự đó trở đi. Nếu s[i] được phép và
trả thẳng một byte, kết quả đôi khi đúng (khi i rơi vào một ký tự ASCII) và đôi khi vô nghĩa (khi
i rơi giữa hai byte của cùng một ký tự có dấu) — đúng sai phụ thuộc vào nội dung chuỗi tại thời
điểm chạy, một loại lỗi cực khó nhận ra qua test. Rust chọn cấm hẳn kiểu đánh chỉ số này thay vì để
nó “thường thì đúng” — buộc lộ vấn đề ngay lúc biên dịch bằng lỗi thiếu trait Index, thay vì để nó
âm thầm ẩn nấp tới khi gặp đúng chuỗi có ký tự nhiều byte ở đúng vị trí oái oăm.
Tệ hơn nữa, một chuỗi Unicode có thể nhìn theo ba tầng khác nhau, và cả ba đều là cách hiểu “hợp lệ”:
- Byte: đơn vị lưu trữ thật sự,
s.bytes(). - Ký tự (
char, Unicode scalar value — xem lại chương Kiểu dữ liệu):s.chars(). Một số ký tự có dấu biểu diễn được bằng mộtchardựng sẵn (dạng dựng sẵn — precomposed, ví dụ'à'), hoặc bằng haicharghép lại — chữ cái gốc cộng dấu kết hợp riêng (combining character, ví dụ'a'rồi dấu huyền'\u{0300}'đứng sau) — cả hai cách đều hiển thị ra “à” giống hệt nhau. - Grapheme cluster: điều người đọc coi là “một chữ” trên màn hình — gần với cách một người Việt
đếm số chữ trong một từ nhất, nhưng thư viện chuẩn Rust không có sẵn khái niệm này, phải dùng
crate ngoài (ví dụ
unicode-segmentation) nếu cần đếm hay cắt chuỗi đúng theo “chữ” thị giác.
Vì “chỉ số 0 của chuỗi” có thể trả lời hoàn toàn khác nhau tuỳ theo hiểu theo tầng nào trong ba tầng
trên, Rust không tự chọn một cách hiểu thay người viết code — buộc phải nói rõ ràng muốn duyệt theo
byte hay theo char.
Slice một String
Vẫn lấy được một phần chuỗi bằng cú pháp slice, nhưng chỉ số là chỉ số byte, và chương trình panic lúc chạy nếu chỉ số đó rơi vào giữa một ký tự nhiều byte:
fn main() {
let hello = String::from("hello");
let s = &hello[0..3]; // "hel" — an toàn vì ASCII, mỗi ký tự đúng 1 byte
println!("{s}");
}
Duyệt qua từng phần
Chọn .chars() hay .bytes() tuỳ mục đích — hầu hết logic xử lý văn bản có ý nghĩa (đếm ký tự, in
từng ký tự) nên dùng .chars(); xử lý ở mức thấp (mã hoá, ghi ra network) mới cần .bytes():
fn main() {
for c in "chào".chars() {
print!("[{c}]");
}
println!();
for b in "chào".bytes() {
print!("{b} ");
}
println!();
}
HashMap
HashMap<K, V> lưu cặp khoá-giá trị, tra cứu theo khoá thay vì theo chỉ số liên tiếp như Vec.
Khác Vec/String, HashMap không có trong prelude — phải đưa vào scope tường minh:
use std::collections::HashMap;
fn main() {
let mut diem: HashMap<String, i32> = HashMap::new();
diem.insert(String::from("Đội Xanh"), 10);
diem.insert(String::from("Đội Đỏ"), 50);
let ten = String::from("Đội Xanh");
let diem_doi_xanh = diem.get(&ten).copied().unwrap_or(0);
println!("{diem_doi_xanh}");
}
get trả về Option<&V> — None khi khoá không tồn tại, cùng triết lý với Vec::get: việc tra
cứu không thấy là một khả năng bình thường, không phải lỗi để panic. .copied() chuyển
Option<&i32> thành Option<i32> (vì i32 là Copy), .unwrap_or(0) cho một giá trị mặc định
khi là None.
Duyệt qua toàn bộ cặp bằng for, thứ tự không đảm bảo giống lần chèn (khác Vec, HashMap không
giữ thứ tự):
use std::collections::HashMap;
fn main() {
let mut diem = HashMap::new();
diem.insert(String::from("Đội Xanh"), 10);
diem.insert(String::from("Đội Đỏ"), 50);
for (ten, gia_tri) in &diem {
println!("{ten}: {gia_tri}");
}
}
Quyền sở hữu khi insert
Với kiểu implement Copy (như i32), giá trị được copy vào map. Với kiểu sở hữu dữ liệu trên heap
(như String), insert move giá trị vào map — quy tắc move/copy quen thuộc từ chương Quyền sở
hữu áp dụng y hệt, chỉ khác chỗ đích là bên trong một HashMap thay vì một biến:
use std::collections::HashMap;
fn main() {
let ten_doi = String::from("Đội Xanh");
let mut diem = HashMap::new();
diem.insert(ten_doi, 10);
println!("{ten_doi}"); // lỗi: ten_doi đã bị move vào diem
}
Cập nhật giá trị
Ba tình huống thường gặp: ghi đè, chỉ ghi nếu khoá chưa có giá trị, và cập nhật dựa trên giá trị cũ.
Ghi đè đơn giản là gọi insert lại với cùng khoá — giá trị cũ bị thay thế. Chỉ ghi khi khoá chưa
có dùng entry, tránh phải tự kiểm tra contains_key rồi mới insert (dễ viết sai, và phải tra
cứu khoá hai lần):
use std::collections::HashMap;
fn main() {
let mut diem = HashMap::new();
diem.insert(String::from("Xanh"), 10);
diem.entry(String::from("Xanh")).or_insert(50); // đã có, không đổi
diem.entry(String::from("Vàng")).or_insert(50); // chưa có, chèn 50
println!("{diem:?}");
}
Cập nhật dựa trên giá trị cũ — ví dụ đếm số lần xuất hiện của mỗi từ trong một câu — kết hợp
entry/or_insert với việc mượn khả biến giá trị trả về:
use std::collections::HashMap;
fn main() {
let van_ban = "con mèo ngồi trên thảm con chó ngồi dưới bàn";
let mut dem = HashMap::new();
for tu in van_ban.split_whitespace() {
let so_dem = dem.entry(tu).or_insert(0);
*so_dem += 1;
}
println!("{dem:?}");
}
or_insert trả về &mut V — một tham chiếu khả biến trỏ thẳng vào giá trị bên trong map (chèn 0
trước nếu khoá chưa có), nên *so_dem += 1 sửa trực tiếp giá trị nằm trong HashMap, không phải
một bản sao.
Xử lý lỗi
Rust chia lỗi thành hai nhóm, xử lý bằng hai cơ chế khác hẳn nhau: lỗi có thể phục hồi
(recoverable) — hợp lý để báo cho người gọi biết rồi để họ quyết định tiếp tục thế nào, dùng
Result<T, E>; và lỗi không thể phục hồi (unrecoverable) — dấu hiệu một bug hoặc một trạng thái
không còn cách nào xử lý tiếp an toàn, dùng panic!.
panic! và lỗi không thể phục hồi
panic! in ra thông báo lỗi, dọn dẹp (unwind) ngăn xếp gọi hàm, rồi thoát chương trình:
fn main() {
panic!("gặp sự cố nghiêm trọng");
}
Phần lớn thời gian panic không tới từ gọi macro trực tiếp mà từ một thao tác mà bản thân nó đã panic khi gặp điều kiện bất thường — chỉ số vượt quá độ dài mảng (đã gặp ở chương Collection) là ví dụ điển hình nhất.
Mặc định, đặt biến môi trường RUST_BACKTRACE=1 khi chạy chương trình để in ra backtrace — danh
sách mọi hàm đã gọi tới điểm panic, giúp xác định chính xác chuỗi lời gọi gây ra lỗi, không chỉ điểm
panic cuối cùng.
unwind hay abort
Khi panic, Rust mặc định unwind: đi ngược lên từng hàm trong ngăn xếp gọi, dọn dẹp dữ liệu của
từng hàm đó (chạy các logic drop tương ứng) trước khi thoát — an toàn nhưng tốn công sức dọn dẹp.
Có thể chuyển sang abort: thoát ngay lập tức, để hệ điều hành thu hồi toàn bộ bộ nhớ, không dọn
dẹp gì — nhị phân nhỏ hơn, thoát nhanh hơn, đổi lại không có cơ hội chạy logic dọn dẹp tuỳ chỉnh
nào. Cấu hình trong Cargo.toml:
[profile.release]
panic = "abort"
Result
Lỗi có thể phục hồi được biểu diễn bằng enum có sẵn trong thư viện chuẩn:
enum Result<T, E> {
Ok(T),
Err(E),
}
T là kiểu giá trị khi thành công, E là kiểu lỗi khi thất bại — cả hai đều là generic, mỗi hàm
trả Result tự quyết định T/E cụ thể của mình. File::open là ví dụ quen thuộc: trả về
Result<File, std::io::Error>.
use std::fs::File;
fn main() {
let ket_qua = File::open("hello.txt");
let _file = match ket_qua {
Ok(file) => file,
Err(loi) => panic!("Không mở được file: {loi:?}"),
};
}
Xử lý khác nhau theo loại lỗi
match lồng nhau để phân biệt loại lỗi cụ thể — ví dụ tự tạo file nếu lỗi là “không tìm thấy”,
nhưng vẫn panic với mọi lỗi khác (không đủ quyền, đĩa hỏng, …):
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let ket_qua = File::open("hello.txt");
let _file = match ket_qua {
Ok(file) => file,
Err(loi) => match loi.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(loi_tao) => panic!("Không tạo được file: {loi_tao:?}"),
},
khac => panic!("Không mở được file: {khac:?}"),
},
};
}
unwrap và expect: rút gọn cho trường hợp không cần phân biệt lỗi
unwrap() trả thẳng giá trị T nếu Ok, panic với thông báo mặc định nếu Err. expect(msg) làm
y hệt nhưng cho tự viết thông báo panic — nên dùng expect thay unwrap trong code thật, vì thông
báo tự viết giúp định vị đúng chỗ gây lỗi nhanh hơn nhiều so với thông báo mặc định chung chung:
use std::fs::File;
fn main() {
let _file =
File::open("chac_chan_khong_ton_tai.txt").expect("file cấu hình phải tồn tại sẵn");
}
Lan truyền lỗi ra ngoài
Một hàm gặp lỗi không nhất thiết phải tự xử lý ngay — trả Err ra ngoài để hàm gọi nó quyết định là
lựa chọn hợp lý, đặc biệt khi hàm gọi có nhiều ngữ cảnh hơn để biết nên làm gì tiếp:
use std::fs::File;
use std::io::{self, Read};
fn doc_ten_tu_file() -> Result<String, io::Error> {
let ket_qua_mo = File::open("ten.txt");
let mut file = match ket_qua_mo {
Ok(file) => file,
Err(loi) => return Err(loi),
};
let mut ten = String::new();
match file.read_to_string(&mut ten) {
Ok(_) => Ok(ten),
Err(loi) => Err(loi),
}
}
fn main() {
match doc_ten_tu_file() {
Ok(ten) => println!("Tên: {ten}"),
Err(loi) => println!("Lỗi: {loi}"),
}
}
Toán tử ?
Mẫu “match rồi return Err sớm nếu lỗi, dùng tiếp giá trị nếu Ok” lặp lại đủ nhiều để có hẳn một toán
tử riêng. ? đặt sau một expression kiểu Result: nếu Ok(gia_tri), biểu thức trả về gia_tri để
dùng tiếp; nếu Err(loi), return ngay Err(loi) khỏi hàm hiện tại — viết lại hàm trên:
use std::fs::File;
use std::io::{self, Read};
fn doc_ten_tu_file() -> Result<String, io::Error> {
let mut file = File::open("ten.txt")?;
let mut ten = String::new();
file.read_to_string(&mut ten)?;
Ok(ten)
}
fn main() {
match doc_ten_tu_file() {
Ok(ten) => println!("Tên: {ten}"),
Err(loi) => println!("Lỗi: {loi}"),
}
}
? chỉ dùng được trong hàm có kiểu trả về tương thích (Result, Option, hoặc kiểu khác implement
FromResidual) — không thể rải ? ở giữa một hàm trả () chỉ vì tiện. Khi kiểu lỗi của expression
khác kiểu lỗi hàm đang trả về, ? còn tự gọi From::from để chuyển đổi — đây là lý do rất nhiều
hàm trong code Rust thực tế trả về Result<T, Box<dyn std::error::Error>>: Box<dyn Error> nhận
được chuyển đổi từ hầu hết mọi kiểu lỗi cụ thể, cho phép dùng ? với nhiều nguồn lỗi khác nhau
trong cùng một hàm mà không cần tự viết converter cho từng loại.
? cũng dùng được với Option<T>: None khiến hàm return None ngay, Some(gia_tri) cho ra
gia_tri để dùng tiếp — hữu ích khi cần lan truyền “không tìm thấy” qua nhiều bước tra cứu lồng
nhau.
Khi nào nên panic
Không có công thức cứng, nhưng có một câu hỏi định hướng: người gọi hàm này có nên có cơ hội quyết
định phải làm gì tiếp theo hay không? Nếu có, trả Result — để họ tự chọn: thử lại, dùng giá trị
mặc định, hay dừng hẳn. Nếu không — vì tiếp tục chạy đã là không an toàn, vô nghĩa, hoặc chính bản
thân việc gọi hàm với đầu vào như vậy đã là một lỗi lập trình — panic! là lựa chọn đúng.
Prototype, ví dụ, và test
Trong code ví dụ minh hoạ hay bản nháp đang phát triển, unwrap()/expect() là lựa chọn hợp lý để
đánh dấu rõ “chỗ này cần xử lý lỗi tử tế sau” mà không phải viết match đầy đủ ngay — miễn nhớ quay
lại trước khi coi là code sẵn sàng dùng thật. Trong test, một lệnh gọi panic khi có lỗi chính là
điều muốn xảy ra: test cần thất bại rõ ràng, không nên âm thầm bỏ qua lỗi rồi báo test pass.
Khi biết nhiều hơn trình biên dịch
Đôi khi logic của người viết code đảm bảo một Result chắc chắn là Ok, nhưng trình biên dịch
không có cách nào biết điều đó chỉ từ kiểu dữ liệu:
use std::net::IpAddr;
fn main() {
let dia_chi_nha: IpAddr = "127.0.0.1"
.parse()
.expect("chuỗi hardcode nên chắc chắn là địa chỉ IP hợp lệ");
println!("{dia_chi_nha}");
}
Chuỗi "127.0.0.1" là hằng viết cứng trong code, không đến từ input người dùng — không có cách nào
nó parse lỗi trừ khi tự gõ sai. expect với thông báo giải thích vì sao tin chắc là Ok vẫn
tốt hơn hẳn so với im lặng giả định — nếu giả định đó một ngày sai (ví dụ ai đó sửa chuỗi thành giá
trị không hợp lệ), panic sẽ chỉ thẳng đúng chỗ, kèm lý do đã tưởng nó không thể sai.
Khi nào nên chủ động panic
Cân nhắc panic khi cả ba điều sau đúng:
- Trạng thái hiện tại là bất thường, không phải một khả năng xảy ra định kỳ trong vận hành bình
thường (khác lỗi “hết dung lượng đĩa” hay “không có mạng” — những thứ chắc chắn sẽ xảy ra vào lúc
nào đó và cần code gọi được xử lý qua
Result). - Code phía sau điểm đó cần giả định trạng thái đã hợp lệ để chạy tiếp an toàn.
- Không có cách hợp lý nào mã hoá ràng buộc đó vào hệ thống kiểu để trình biên dịch tự bắt lỗi thay.
Gọi một hàm với giá trị vi phạm hợp đồng của nó (ví dụ số âm cho một hàm chỉ nhận số dương) là lỗi
của code gọi, không phải điều hàm đó nên trả Result để người gọi “xử lý” — không có cách xử lý
hợp lý nào ngoài việc sửa lại code gọi cho đúng.
Mã hoá ràng buộc vào kiểu dữ liệu
Thay vì kiểm tra cùng một điều kiện rải rác ở nhiều nơi trong code, gói điều kiện đó vào một kiểu riêng, kiểm tra một lần duy nhất lúc khởi tạo:
pub struct PhanTram {
gia_tri: i32,
}
impl PhanTram {
pub fn moi(gia_tri: i32) -> PhanTram {
if !(0..=100).contains(&gia_tri) {
panic!("Phần trăm phải trong khoảng 0-100, nhận được {gia_tri}");
}
PhanTram { gia_tri }
}
pub fn gia_tri(&self) -> i32 {
self.gia_tri
}
}
fn main() {
let hoan_thanh = PhanTram::moi(75);
println!("{}%", hoan_thanh.gia_tri());
}
Vì gia_tri là field riêng tư và chỉ tạo được PhanTram qua moi (đã kiểm tra ràng buộc), mọi
hàm khác nhận tham số kiểu PhanTram được quyền tin tưởng giá trị bên trong luôn nằm trong khoảng
0-100 mà không cần tự kiểm tra lại — panic xảy ra đúng một chỗ, tại điểm vi phạm hợp đồng thật sự
(lúc khởi tạo với giá trị sai), thay vì để giá trị sai âm thầm chui sâu vào logic rồi gây lỗi khó dò
ở một chỗ xa hoàn toàn không liên quan tới nguyên nhân gốc.
Generic, Trait, Lifetime
Ba khái niệm của chương này giải quyết ba vấn đề khác nhau nhưng đều xoay quanh việc viết code dùng lại được: generic tránh lặp code xử lý cùng logic trên nhiều kiểu cụ thể, trait định nghĩa hành vi chung mà nhiều kiểu khác nhau có thể cùng implement, lifetime đảm bảo mọi tham chiếu dùng lại vẫn luôn hợp lệ. Cả ba đều được trình biên dịch giải quyết tại thời điểm biên dịch — không cái nào có chi phí lúc chạy.
Generic
Xét hai hàm gần như giống hệt nhau, chỉ khác kiểu tham số:
fn so_lon_nhat_i32(danh_sach: &[i32]) -> &i32 {
let mut lon_nhat = &danh_sach[0];
for so in danh_sach {
if so > lon_nhat {
lon_nhat = so;
}
}
lon_nhat
}
fn so_lon_nhat_char(danh_sach: &[char]) -> &char {
let mut lon_nhat = &danh_sach[0];
for ky_tu in danh_sach {
if ky_tu > lon_nhat {
lon_nhat = ky_tu;
}
}
lon_nhat
}
fn main() {
let so = vec![34, 50, 25, 100, 65];
println!("{}", so_lon_nhat_i32(&so));
}
Phần thân hai hàm giống hệt nhau — chỉ kiểu i32/char khác. Generic cho viết một hàm duy nhất,
tham số hoá theo kiểu:
fn so_lon_nhat<T: PartialOrd + Copy>(danh_sach: &[T]) -> T {
let mut lon_nhat = danh_sach[0];
for &item in danh_sach {
if item > lon_nhat {
lon_nhat = item;
}
}
lon_nhat
}
fn main() {
let so = vec![34, 50, 25, 100, 65];
println!("{}", so_lon_nhat(&so));
let ky_tu = vec!['y', 'm', 'a', 'q'];
println!("{}", so_lon_nhat(&ky_tu));
}
T là tham số kiểu, đứng trong <> ngay sau tên hàm — quy ước đặt tên một chữ hoa (T, U, …)
khi không có ý nghĩa cụ thể hơn tên đó có thể gợi ra. T: PartialOrd + Copy là ràng buộc trait
(trait bound, nói kỹ ở phần Trait ngay sau đây): không phải T nhận bất kỳ kiểu nào, chỉ những
kiểu implement PartialOrd (so sánh được bằng >) và Copy (copy được thay vì move khi gán) mới
hợp lệ — thiếu ràng buộc, so > lon_nhat không biên dịch được vì trình biên dịch không có gì đảm
bảo T hỗ trợ phép so sánh đó.
Generic trên struct và enum
struct Diem<T> {
x: T,
y: T,
}
fn main() {
let nguyen = Diem { x: 5, y: 10 };
let thuc = Diem { x: 1.0, y: 4.0 };
println!("{} {}", nguyen.x, thuc.y);
}
Cả x và y cùng dùng một T — một instance phải có cả hai field cùng kiểu, viết
Diem { x: 5, y: 4.0 } không biên dịch được vì 5 là i32 còn 4.0 là f64. Cần hai field khác
kiểu độc lập, khai báo hai tham số: struct Diem<T, U> { x: T, y: U }. Option<T> và
Result<T, E> đã gặp ở các chương trước chính là enum generic — không có gì đặc biệt hơn cách viết
ở đây.
Method trên kiểu generic
struct Diem<T> {
x: T,
y: T,
}
impl<T> Diem<T> {
fn lay_x(&self) -> &T {
&self.x
}
}
impl Diem<f64> {
fn khoang_cach_tu_goc(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Diem { x: 3.0, y: 4.0 };
println!("{} {}", p.lay_x(), p.khoang_cach_tu_goc());
}
impl<T> Diem<T> khai báo method cho mọi T. impl Diem<f64> (không có <T> sau impl) chỉ
khai báo method cho riêng trường hợp T là f64 — hữu ích khi một phép tính (ở đây là
khoang_cach_tu_goc, cần sqrt) chỉ có ý nghĩa với một kiểu cụ thể, không tổng quát hoá được cho
mọi T.
Generic không có chi phí lúc chạy
Trình biên dịch monomorphize (đơn hình hoá) code generic: với mỗi kiểu cụ thể được dùng thật ở
đâu đó trong code, trình biên dịch tự sinh ra một bản sao code đã thay T bằng kiểu cụ thể đó, y
hệt như thể người viết code tự tay viết so_lon_nhat_i32 và so_lon_nhat_char riêng biệt. Nhờ vậy
dùng generic không hề chậm hơn viết tay từng phiên bản cụ thể — toàn bộ phần “tổng quát hoá” chỉ
tồn tại lúc biên dịch, biến mất hoàn toàn trong binary cuối cùng.
Trait
Trait định nghĩa một tập hành vi mà nhiều kiểu khác nhau có thể cùng implement — giống một bản cam kết “kiểu nào implement trait này thì chắc chắn có các method sau”.
pub trait TomTat {
fn tom_tat(&self) -> String;
}
pub struct BaiBao {
pub tieu_de: String,
pub tac_gia: String,
}
impl TomTat for BaiBao {
fn tom_tat(&self) -> String {
format!("{}, bởi {}", self.tieu_de, self.tac_gia)
}
}
pub struct Tweet {
pub ten_nguoi_dung: String,
pub noi_dung: String,
}
impl TomTat for Tweet {
fn tom_tat(&self) -> String {
format!("@{}: {}", self.ten_nguoi_dung, self.noi_dung)
}
}
fn main() {
let bai_bao = BaiBao {
tieu_de: String::from("Rust 2024"),
tac_gia: String::from("Đội ngũ Rust"),
};
println!("{}", bai_bao.tom_tat());
}
BaiBao và Tweet không liên quan gì nhau về cấu trúc dữ liệu, nhưng cùng implement TomTat — bất
kỳ hàm nào chỉ cần “một thứ biết tự tóm tắt” đều nhận được cả hai, không cần quan tâm chúng thật sự
là gì.
Một ràng buộc gọi là quy tắc mồ côi (orphan rule): chỉ implement được một trait cho một kiểu nếu ít nhất một trong hai — trait hoặc kiểu — được định nghĩa ngay trong crate hiện tại. Không thể implement một trait của thư viện ngoài cho một kiểu cũng của thư viện ngoài khác. Ràng buộc này đảm bảo tính nhất quán (coherence): không bao giờ có hai crate khác nhau cùng implement một trait cho cùng một kiểu theo hai cách khác nhau, gây xung đột không thể giải quyết khi cả hai được dùng chung trong một chương trình.
Trait mặc định
Một method trong trait có thể có sẵn phần thân — kiểu implement trait được quyền dùng luôn bản mặc định, hoặc ghi đè nếu cần:
pub trait TomTat {
fn tom_tat(&self) -> String {
String::from("(Đọc thêm...)")
}
}
pub struct BaiBao {
pub tieu_de: String,
}
impl TomTat for BaiBao {} // dùng nguyên bản mặc định, không ghi đè gì
fn main() {
let bb = BaiBao {
tieu_de: String::from("Rust 2024"),
};
println!("{}", bb.tom_tat());
}
Trait làm tham số
Nhận một tham số “kiểu nào cũng được, miễn implement trait X” bằng impl Trait:
#![allow(unused)]
fn main() {
pub trait TomTat {
fn tom_tat(&self) -> String;
}
pub fn thong_bao(item: &impl TomTat) {
println!("Tin mới! {}", item.tom_tat());
}
}
&impl TomTat là cách viết gọn của cú pháp ràng buộc trait đầy đủ hơn:
pub fn thong_bao<T: TomTat>(item: &T) {
println!("Tin mới! {}", item.tom_tat());
}
Hai cách viết tương đương khi chỉ có một tham số; dạng <T: TomTat> cần thiết khi muốn ép hai
tham số phải cùng một kiểu cụ thể (fn ham<T: TomTat>(a: &T, b: &T) — a và b buộc cùng kiểu;
viết &impl TomTat cho cả hai tham số cho phép a, b là hai kiểu khác nhau, miễn cả hai đều
implement TomTat).
Nhiều ràng buộc nối bằng +, hoặc tách ra where cho dễ đọc khi ràng buộc dài:
fn ham<T: TomTat + Clone>(item: &T) {}
fn ham_khac<T>(item: &T)
where
T: TomTat + Clone,
{
}
Trả về một kiểu implement trait
pub trait TomTat {
fn tom_tat(&self) -> String;
}
pub struct Tweet {
pub noi_dung: String,
}
impl TomTat for Tweet {
fn tom_tat(&self) -> String {
self.noi_dung.clone()
}
}
fn tao_tweet() -> impl TomTat {
Tweet {
noi_dung: String::from("nội dung mới"),
}
}
fn main() {
println!("{}", tao_tweet().tom_tat());
}
impl TomTat ở vị trí trả về nghĩa là “trả về một kiểu cụ thể nào đó implement TomTat, không tiết
lộ chính xác là kiểu gì” — hữu ích khi kiểu trả về thật sự dài dòng để viết ra (ví dụ closure ở
chương sau) hoặc không muốn cam kết công khai đúng kiểu cụ thể. Giới hạn quan trọng: cách này chỉ
dùng được khi hàm luôn trả về đúng một kiểu cụ thể duy nhất ở mọi nhánh — không thể trả Tweet
ở nhánh này và BaiBao ở nhánh khác dù cả hai đều implement TomTat, vì trình biên dịch cần biết
kích thước chính xác của kiểu trả về ngay tại thời điểm biên dịch. Khi thật sự cần trả về nhiều kiểu
cụ thể khác nhau tuỳ điều kiện, cần tới trait object — nói ở
chương Trait object và hướng đối tượng.
Lifetime
Mọi tham chiếu trong Rust có một lifetime — thời hạn sống, tức phạm vi code mà tham chiếu đó còn hợp lệ. Phần lớn thời gian trình biên dịch tự suy luận được (elision, nói ở phần sau), giống suy luận kiểu — nhưng đôi khi một chữ ký hàm có nhiều cách gán lifetime hợp lệ, và trình biên dịch cần được nói rõ nên chọn cách nào.
Vấn đề lifetime giải quyết
fn dai_nhat(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {}
Lỗi biên dịch: “missing lifetime specifier”. Hàm trả về một trong hai tham chiếu tham số, tuỳ
điều kiện if — nhưng chữ ký hàm không nói rõ tham chiếu trả về gắn với x hay y. Borrow checker
cần biết chính xác điều đó để xác nhận: tham chiếu trả về có chắc chắn còn hợp lệ tại nơi gọi hàm
hay không, tuỳ vào x, y sống được bao lâu ở chỗ gọi. Không tự suy ra được từ code, nên trình
biên dịch từ chối đoán.
Sửa bằng cách gắn cùng một tham số lifetime 'a cho cả hai tham số và giá trị trả về:
fn dai_nhat<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = String::from("chuỗi dài hơn");
let s2 = String::from("ngắn");
let ket_qua = dai_nhat(s1.as_str(), s2.as_str());
println!("Chuỗi dài nhất là {ket_qua}");
}
'a không tạo ra hay kéo dài lifetime nào — nó chỉ mô tả một ràng buộc cho borrow checker:
“tham chiếu trả về hợp lệ trong đúng khoảng thời gian mà cả x lẫn y cùng hợp lệ” (giao của
hai lifetime, chọn cái ngắn hơn). Nhờ ràng buộc đó, borrow checker bắt được đúng lúc dùng sai:
fn dai_nhat<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = String::from("chuỗi dài hơn");
let ket_qua;
{
let s2 = String::from("ngắn");
ket_qua = dai_nhat(s1.as_str(), s2.as_str());
}
println!("Chuỗi dài nhất là {ket_qua}"); // lỗi: s2 không còn sống tới đây
}
ket_qua mang lifetime bị ràng buộc bởi cả s1 và s2 — dù giá trị thực tế trả về ở lần chạy
này tình cờ luôn là s1 (chuỗi dài hơn), trình biên dịch không chạy thử để biết điều đó, nó chỉ
nhìn chữ ký hàm và phải chấp nhận khả năng xấu nhất: ket_qua có thể là s2, nên không cho
ket_qua sống lâu hơn s2.
Lifetime trên struct
Một struct giữ tham chiếu (thay vì sở hữu dữ liệu) phải khai báo lifetime cho field đó — struct không thể sống lâu hơn dữ liệu nó tham chiếu tới:
struct TrichDan<'a> {
phan: &'a str,
}
fn main() {
let tieu_thuyet = String::from("Gọi tôi là Ishmael. Một vài năm trước.");
let cau_dau = tieu_thuyet.split('.').next().unwrap();
let td = TrichDan { phan: cau_dau };
println!("{}", td.phan);
}
Lifetime elision: vì sao không phải lúc nào cũng thấy 'a
Rất nhiều hàm nhận và trả về tham chiếu mà không cần viết lifetime tường minh — nhờ vài quy tắc suy luận cố định (elision rules) áp dụng tự động khi chữ ký hàm khớp một trong các khuôn mẫu phổ biến: đặc biệt, nếu chỉ có đúng một tham số kiểu tham chiếu, lifetime của nó tự động gán cho mọi tham chiếu trong giá trị trả về:
fn tu_dau_tien(s: &str) -> &str {
s.split_whitespace().next().unwrap_or("")
}
fn main() {
let s = String::from("hello world");
println!("{}", tu_dau_tien(&s));
}
Chỉ khi có từ hai tham chiếu đầu vào trở lên và các quy tắc suy luận không xác định được duy nhất
một cách gán hợp lý (như hàm dai_nhat ở trên) mới cần viết 'a tường minh.
Lifetime ’static
'static là lifetime đặc biệt: tham chiếu sống được suốt vòng đời chương trình. Mọi string
literal đều có kiểu &'static str, vì nội dung của nó nằm sẵn trong binary đã biên dịch, tồn tại
liên tục từ lúc chương trình chạy tới khi kết thúc:
fn main() {
let s: &'static str = "Tôi sống suốt chương trình.";
println!("{s}");
}
Thông báo lỗi gợi ý thêm 'static để “sửa nhanh” một lỗi lifetime nên được cân nhắc kỹ chứ không áp
dụng máy móc — ép một tham chiếu thường thành 'static thường là dấu hiệu đang cố giữ một tham
chiếu sống lâu hơn dữ liệu gốc của nó thật sự cho phép, chứ không phải cách sửa đúng gốc rễ vấn đề.
Kiểm thử tự động
Rust coi test là một phần của ngôn ngữ, không phải công cụ ngoài gắn thêm vào: #[test] là một
attribute có sẵn, cargo test biên dịch và chạy mọi hàm đánh dấu attribute đó.
Viết test
Một hàm trở thành test bằng cách thêm #[test] ngay phía trên:
#![allow(unused)]
fn main() {
pub fn cong(a: i32, b: i32) -> i32 {
a + b
}
#[test]
fn kiem_tra_cong() {
assert_eq!(4, cong(2, 2));
}
}
cargo test tìm mọi hàm #[test] trong crate, chạy từng hàm trong một luồng riêng. Test được coi
là fail nếu hàm panic (do một macro assert thất bại, hoặc panic thủ công) — không panic nghĩa là
pass.
Các macro assert
assert!(dieu_kien)— panic nếudieu_kienlàfalse.assert_eq!(trai, phai)— panic nếu hai vế không bằng nhau, in ra cả hai giá trị trong thông báo lỗi (yêu cầu cả hai kiểu implementPartialEqđể so sánh vàDebugđể in ra khi fail).assert_ne!(trai, phai)— ngược lại, panic nếu hai vế bằng nhau.
#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq)]
struct DiemSo {
gia_tri: i32,
}
#[test]
fn diem_so_bang_nhau() {
let a = DiemSo { gia_tri: 10 };
let b = DiemSo { gia_tri: 10 };
assert_eq!(a, b);
}
}
So với assert!(a == b), assert_eq!(a, b) cho thông báo lỗi hữu ích hơn hẳn khi fail — in ra
chính xác hai giá trị khác nhau ở đâu, thay vì chỉ nói “điều kiện sai”.
Thông báo lỗi tuỳ chỉnh
Mọi macro assert nhận thêm tham số tuỳ chọn sau điều kiện chính, truyền thẳng vào format!:
#![allow(unused)]
fn main() {
pub fn chao(ten: &str) -> String {
format!("Chào, {ten}!")
}
#[test]
fn loi_chao_chua_ten() {
let ket_qua = chao("Mai");
assert!(
ket_qua.contains("Mai"),
"Lời chào không chứa tên, giá trị thật sự là: `{ket_qua}`"
);
}
}
Kiểm tra panic với should_panic
#[should_panic] đảo ngược tiêu chí pass/fail: test pass khi hàm panic, fail nếu hàm chạy
xong không panic:
#![allow(unused)]
fn main() {
pub struct PhanTram {
gia_tri: i32,
}
impl PhanTram {
pub fn moi(gia_tri: i32) -> PhanTram {
if !(0..=100).contains(&gia_tri) {
panic!("Phần trăm phải trong khoảng 0-100, nhận được {gia_tri}");
}
PhanTram { gia_tri }
}
}
#[test]
#[should_panic]
fn tao_voi_gia_tri_vuot_khoang_thi_panic() {
PhanTram::moi(200);
}
}
#[should_panic] không kiểm tra panic đó có phải panic mình mong đợi hay không — bất kỳ panic
nào trong hàm cũng khiến test pass, kể cả một panic hoàn toàn không liên quan (ví dụ do bug khác gây
ra trước khi chạy tới đoạn logic thật sự muốn kiểm tra). Thêm expected để chỉ chấp nhận panic có
thông báo chứa đúng chuỗi mong đợi, thu hẹp lại đúng trường hợp cần test:
#[test]
#[should_panic(expected = "trong khoảng 0-100")]
fn tao_voi_gia_tri_vuot_khoang_thi_panic() {
PhanTram::moi(200);
}
Dùng Result trong test
Một test có thể trả Result<(), E> thay vì panic, cho phép dùng toán tử ? ngay trong thân test —
tiện khi test gọi nhiều thao tác có thể lỗi và muốn lan lỗi ra thay vì unwrap từng bước:
#![allow(unused)]
fn main() {
#[test]
fn phan_tich_so_thanh_cong() -> Result<(), std::num::ParseIntError> {
let so: i32 = "42".parse()?;
assert_eq!(so, 42);
Ok(())
}
}
Test fail khi hàm trả Err. Không thể kết hợp #[should_panic] với kiểu test trả Result — hai cơ
chế báo fail (panic vs Err) không tương thích nhau trong cùng một test.
Chạy test
cargo test biên dịch code ở chế độ test rồi chạy binary test sinh ra; các cờ sau dấu -- được
truyền thẳng cho binary đó thay vì cho cargo test (cargo test -- --cờ-của-binary).
Chạy song song và trạng thái chia sẻ
Mặc định, cargo test chạy mỗi test trên một luồng riêng, song song — nhanh hơn chạy tuần tự,
nhưng kéo theo một hệ quả dễ bị bỏ qua: nếu nhiều test cùng đọc/ghi một trạng thái chung (một file
trên đĩa, một biến môi trường, một thư mục làm việc), chúng có thể va vào nhau và cho kết quả sai
lệch tuỳ thứ tự chạy — đúng dạng lỗi khó dò vì không tái hiện được ổn định. Ép chạy tuần tự khi test
có chia sẻ trạng thái:
cargo test -- --test-threads=1
Output của test
Theo mặc định, cargo test ẩn toàn bộ output (println!, …) của một test pass — chỉ hiện
khi test đó fail, để kết quả chạy test gọn, không bị ngợp bởi log của hàng trăm test đều pass. Muốn
luôn thấy output kể cả khi pass:
cargo test -- --show-output
Chạy một tập con test
Chạy test theo tên chính xác:
cargo test kiem_tra_cong
Hoặc theo một phần tên chung — mọi test có tên chứa chuỗi đó đều chạy, tiện khi đặt tên test có tiền tố chung theo nhóm chức năng:
cargo test kiem_tra
Bỏ qua test tốn thời gian
Đánh dấu #[ignore] cho test chạy lâu (tích hợp, benchmark thô) để cargo test bình thường bỏ qua,
chỉ chạy khi được yêu cầu rõ ràng:
#![allow(unused)]
fn main() {
#[test]
fn test_nhanh() {
assert_eq!(2 + 2, 4);
}
#[test]
#[ignore]
fn test_cham() {
// giả sử đây là một thao tác tốn nhiều thời gian
assert_eq!(2 + 2, 4);
}
}
cargo test # bỏ qua test_cham
cargo test -- --ignored # chỉ chạy các test đã đánh dấu ignore
Tổ chức test
Rust phân biệt hai loại test theo phạm vi: unit test — nhỏ, tập trung, có thể chạm cả vào code riêng tư; và integration test — chạy từ bên ngoài, chỉ thấy đúng API công khai như một người dùng thật của crate.
Unit test
Quy ước: đặt unit test trong một module con tên tests ngay trong cùng file với code đang test,
đánh dấu #[cfg(test)] phía trên module đó:
#![allow(unused)]
fn main() {
pub fn cong_hai(a: i32) -> i32 {
them_hai_noi_bo(a)
}
fn them_hai_noi_bo(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_hoat_dong() {
assert_eq!(4, cong_hai(2));
assert_eq!(5, them_hai_noi_bo(3)); // gọi được cả hàm riêng tư
}
}
}
#[cfg(test)] báo trình biên dịch: chỉ biên dịch module này khi build ở chế độ test (cargo test),
loại hẳn khỏi build thường (cargo build) — code test không hề làm phình to hay chậm binary
release. use super::*; đưa toàn bộ item của module cha (kể cả các item riêng tư) vào scope của
tests — hợp lệ vì tests là module con của module chứa code đang test, và (nhắc lại quy tắc
riêng tư ở chương Module) con luôn nhìn thấy được item riêng tư của cha. Đây là lý do unit test gọi
thẳng được them_hai_noi_bo dù hàm đó không pub — kiểm tra được cả chi tiết cài đặt bên trong,
không chỉ API công khai.
Integration test
Test tích hợp nằm trong thư mục tests/ ở gốc dự án (ngang hàng src/), không phải một module
con của crate — mỗi file trong tests/ được Cargo biên dịch thành một crate riêng biệt, chỉ
use được phần pub của thư viện, giống hệt góc nhìn của một người dùng bên ngoài gọi crate:
ten_du_an/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
└── kiem_tra_tich_hop.rs
// tests/kiem_tra_tich_hop.rs
use ten_du_an::cong_hai;
#[test]
fn no_hoat_dong_tu_ben_ngoai() {
assert_eq!(4, cong_hai(2));
}
cargo test tự động chạy cả unit test lẫn mọi file trong tests/, không cần khai báo gì thêm.
Chỉ library crate (có src/lib.rs) mới có tests/ — một binary crate thuần (chỉ có
src/main.rs) không có gì để tests/ use vào, vì code trong src/main.rs không phải API công
khai của ai cả. Vì lý do đó, quy ước phổ biến trong các dự án Rust thực tế: đặt phần lớn logic vào
src/lib.rs, để src/main.rs chỉ còn là một lớp mỏng gọi vào thư viện đó — nhờ vậy toàn bộ logic
thật sự đều test tích hợp được, main.rs chỉ còn vài dòng không cần test riêng.
Chia sẻ code dùng chung giữa các file test
Một file thường (tests/common.rs) bị Cargo hiểu nhầm là một file test độc lập, chạy như một crate
test riêng dù không chứa #[test] nào — để đặt code dùng chung (hàm setup, dữ liệu mẫu) mà không bị
Cargo coi là một bộ test, đặt trong tests/common/mod.rs (dùng quy ước module cũ hơn):
tests/
├── common/
│ └── mod.rs
└── kiem_tra_tich_hop.rs
Cargo không coi các file bên trong thư mục con (tests/common/) là một crate test cấp cao — chỉ các
file nằm trực tiếp trong tests/ mới được coi vậy.
Closure và Iterator
Hai công cụ trung tâm của phong cách lập trình hàm trong Rust: closure — hàm ẩn danh có thể giữ lại (capture) biến từ ngữ cảnh xung quanh nơi nó được tạo, và iterator — cách xử lý một chuỗi giá trị theo từng bước, tính lười (lazy), không tạo collection trung gian nếu không cần. Cả hai đều biên dịch xuống hiệu năng ngang code viết tay bằng vòng lặp thông thường — trừu tượng hoá không có nghĩa là đánh đổi tốc độ.
Closure
fn main() {
let cong_them = |x: i32, y: i32| x + y;
println!("{}", cong_them(2, 3));
}
Cú pháp |tham_so| bieu_thuc định nghĩa một closure. Khác fn, kiểu tham số/trả về thường không
cần viết tường minh — trình biên dịch suy luận từ lần gọi đầu tiên. Vì closure thường dùng ngay
tại chỗ, trong một phạm vi hẹp, phần lớn lợi ích của việc ghi kiểu tường minh cho fn (chữ ký là
tài liệu công khai, đã nói ở chương Hàm) không áp dụng ở đây.
Suy luận chỉ diễn ra một lần, khoá cứng vào kiểu cụ thể đầu tiên gặp phải — không giống generic:
fn main() {
let identity = |x| x;
let n = identity(5);
let s = identity(String::from("chào")); // lỗi: kiểu đã khoá thành i32 từ lần gọi trước
}
Bắt giữ môi trường xung quanh
Điều closure làm được mà một fn thường không làm được: capture biến từ scope bao quanh nơi nó
định nghĩa.
fn main() {
let ten_cong_ty = String::from("Rust Corp");
let gioi_thieu = || println!("Chào mừng tới {ten_cong_ty}");
gioi_thieu();
}
gioi_thieu “nhìn thấy” ten_cong_ty dù đó không phải tham số — closure tự động mượn các biến nó
dùng tới từ scope cha. Có ba cách capture, tương ứng ba trait mà mọi closure implement tuỳ theo cách
nó thật sự dùng biến capture được:
FnOnce: mọi closure ít nhất implement trait này — có thể gọi được, ít nhất một lần. Closure chỉ implementFnOnce(không implement thêm hai trait dưới) là closure move giá trị capture được ra khỏi thân nó, nên chỉ gọi được đúng một lần (gọi lần hai sẽ dùng một giá trị đã bị move).FnMut: capture bằng tham chiếu khả biến, có thể gọi nhiều lần, mỗi lần có thể sửa giá trị đã capture.Fn: capture bằng tham chiếu bất biến (hoặc không capture gì cả), gọi được nhiều lần, không bao giờ sửa gì.
fn main() {
let mut dem = 0;
let mut tang_dem = || {
dem += 1;
println!("Đếm: {dem}");
};
tang_dem();
tang_dem();
}
tang_dem sửa dem mỗi lần gọi — closure này capture dem bằng tham chiếu khả biến, nên biến
tang_dem phải khai báo mut và implement FnMut (không implement Fn, vì Fn không được phép
sửa gì).
Từ khoá move
Thêm move trước closure ép nó lấy quyền sở hữu mọi biến capture được, thay vì chỉ mượn — cần
thiết khi closure phải sống lâu hơn scope hiện tại, ví dụ chuyển cho một luồng khác chạy (nói kỹ ở
chương Concurrency):
fn main() {
let danh_sach = vec![1, 2, 3];
let in_ra = move || println!("{danh_sach:?}");
in_ra();
// danh_sach đã bị move vào closure, không dùng lại được ở đây
}
Nhận closure làm tham số
Một hàm nhận closure qua ràng buộc trait Fn/FnMut/FnOnce — chọn trait nào tuỳ hàm cần gọi
closure bao nhiêu lần và có sửa gì bên trong hay không:
fn ap_dung_hai_lan<F>(f: F) -> i32
where
F: Fn(i32) -> i32,
{
f(1) + f(2)
}
fn main() {
let nhan_ba = |x| x * 3;
println!("{}", ap_dung_hai_lan(nhan_ba));
}
Mỗi closure có một kiểu vô danh riêng biệt do trình biên dịch tự sinh — không có cách viết ra kiểu
cụ thể đó, nên tham số nhận closure luôn phải là generic (F: Fn(...)...) hoặc impl Fn(...)...,
không bao giờ viết được một kiểu cụ thể cho tham số đó.
Iterator
Iterator xử lý một chuỗi giá trị lần lượt từng phần tử, theo trait Iterator trong thư viện chuẩn —
chỉ cần đúng một method bắt buộc:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
next() trả Some(gia_tri) cho từng phần tử, None khi hết. Self::Item là kiểu phần tử — mỗi
kiểu implement Iterator tự quyết định Item cụ thể của mình.
Tính lười
fn main() {
let v = vec![1, 2, 3];
let mut iter = v.iter();
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
println!("{:?}", iter.next());
}
Tạo một iterator (v.iter()) không làm gì cả — không có phần tử nào được xử lý cho tới khi
next() (trực tiếp hoặc gián tiếp qua for, hay các adaptor dưới đây) thật sự được gọi. Đây gọi là
tính lười (lazy): định nghĩa một chuỗi phép biến đổi trên iterator không tốn chi phí gì cho tới
khi có thứ gì đó thật sự “tiêu thụ” (consume) nó.
v.iter() cho iterator các tham chiếu bất biến (&i32, không lấy quyền sở hữu v);
v.into_iter() lấy quyền sở hữu v, cho iterator các giá trị sở hữu hẳn (i32); v.iter_mut()
cho tham chiếu khả biến, sửa được từng phần tử ngay trong lúc duyệt.
Adaptor tiêu thụ
Một số method gọi next() lặp lại cho tới khi hết, “tiêu thụ” hẳn iterator để tính ra một giá trị
duy nhất:
fn main() {
let v = vec![1, 2, 3];
let tong: i32 = v.iter().sum();
println!("{tong}");
}
for cũng tiêu thụ iterator theo cách tương tự, gọi next() ở mỗi vòng lặp — cách viết
for x in v.iter() chẳng qua là đường cú pháp gọn cho đúng cơ chế next() này.
Adaptor biến đổi
Một số method khác không tiêu thụ ngay mà trả về một iterator mới, đã áp thêm một phép biến đổi
— vẫn lười, chưa chạy gì cho tới khi có một adaptor tiêu thụ (hoặc for) phía sau:
fn main() {
let v = vec![1, 2, 3];
let gap_doi: Vec<i32> = v.iter().map(|x| x * 2).collect();
println!("{gap_doi:?}");
}
map không tự chạy — nó trả về một iterator mới, mô tả “sẽ nhân đôi từng phần tử khi được hỏi tới”.
collect() mới là bước thật sự tiêu thụ, gom kết quả vào một collection cụ thể (ở đây là
Vec<i32>, suy luận từ chú thích kiểu — collect generic trên kiểu đích nên gần như luôn cần một
gợi ý kiểu ở đâu đó, qua let hoặc cú pháp turbofish ::<Vec<i32>>()).
filter giữ lại phần tử thoả một điều kiện, thường kết hợp với closure capture biến từ scope ngoài:
#[derive(Debug)]
struct SanPham {
ten: String,
gia: u32,
}
fn loc_theo_ngan_sach(danh_sach: Vec<SanPham>, ngan_sach: u32) -> Vec<SanPham> {
danh_sach
.into_iter()
.filter(|sp| sp.gia <= ngan_sach)
.collect()
}
fn main() {
let san_pham = vec![
SanPham {
ten: String::from("Bàn phím"),
gia: 500,
},
SanPham {
ten: String::from("Chuột"),
gia: 150,
},
];
let phu_hop = loc_theo_ngan_sach(san_pham, 200);
println!("{phu_hop:?}");
}
filter capture ngan_sach từ scope ngoài closure — kết hợp iterator với closure là cách rất phổ
biến để viết logic xử lý dữ liệu ngắn gọn mà không cần vòng lặp for tường minh.
Hiệu năng: trừu tượng hoá chi phí bằng không
Chuỗi map/filter/collect trông như tạo ra nhiều bước trung gian, nhưng trình biên dịch tối ưu
hoá toàn bộ chuỗi adaptor xuống thành mã máy tương đương hệt một vòng lặp for viết tay, không
có overhead gọi hàm hay cấp phát trung gian thừa. Đây là ví dụ điển hình cho triết lý “trừu tượng
hoá chi phí bằng không” (zero-cost abstraction) của Rust — dùng iterator không đánh đổi hiệu năng để
lấy sự ngắn gọn, khác với cảm giác trực giác rằng code “trừu tượng hơn” ắt phải “chậm hơn”.
Cargo và crates.io nâng cao
Chương này đi qua các tính năng của Cargo phục vụ giai đoạn dự án đã lớn hơn một file main.rs đơn
lẻ: tuỳ chỉnh cách biên dịch, viết tài liệu, chia sẻ crate lên crates.io, và tổ chức nhiều crate
liên quan trong một workspace.
Release profile
Cargo biên dịch theo hai profile có sẵn, mỗi profile một tập giá trị mặc định khác nhau:
dev— dùng khi chạycargo build/cargo run(không cờ gì thêm): ưu tiên biên dịch nhanh, chấp nhận binary chạy chậm hơn. Thuận tiện cho vòng lặp sửa-chạy-lại liên tục lúc phát triển.release— dùng khi thêm cờ--release: ưu tiên binary chạy nhanh, chấp nhận biên dịch lâu hơn (trình biên dịch bỏ nhiều công sức tối ưu hơn). Dùng khi build bản phát hành thật.
Ghi đè giá trị mặc định của từng profile trong Cargo.toml:
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
opt-level (0-3) điều khiển mức độ tối ưu hoá — mặc định dev là 0 (tối ưu tối thiểu, biên dịch
nhanh nhất), release là 3 (tối ưu tối đa). Có thể chỉnh riêng, ví dụ tăng opt-level của dev
lên 1 khi code đang phát triển có đoạn tính toán nặng, cần chạy đủ nhanh để test trong lúc code vẫn
chưa hoàn thiện, mà chưa muốn trả chi phí biên dịch đầy đủ của release.
Publish crate lên crates.io
Doc comment
/// viết tài liệu cho item ngay bên dưới nó, hỗ trợ cú pháp Markdown, hiển thị đẹp khi chạy
cargo doc --open (sinh trang HTML tài liệu, mở luôn trong trình duyệt):
#![allow(unused)]
fn main() {
/// Cộng thêm 1 vào số truyền vào.
///
/// # Examples
///
/// ```
/// let ket_qua = ch12::cong_mot(5);
/// assert_eq!(ket_qua, 6);
/// ```
pub fn cong_mot(x: i32) -> i32 {
x + 1
}
}
Các mục thường gặp trong doc comment, đánh dấu bằng heading Markdown: # Examples (ví dụ minh hoạ
cách gọi), # Panics (những tình huống hàm sẽ panic), # Errors (ý nghĩa từng loại lỗi nếu hàm trả
Result), # Safety (điều kiện phải đảm bảo trước khi gọi, bắt buộc có với hàm unsafe, nói ở
chương Tính năng nâng cao).
Khối code trong ví dụ ở # Examples không chỉ là minh hoạ chết — cargo test tự động biên dịch
và chạy mọi khối code trong doc comment như một test riêng (gọi là doctest). Tài liệu sai lệch với
code thật sẽ khiến cargo test đỏ, không thể âm thầm để tài liệu lỗi thời.
//! khác /// ở chỗ nó viết tài liệu cho item chứa nó (thường dùng ở đầu src/lib.rs để mô
tả tổng quan cả crate), không phải item phía dưới:
//! Crate này cung cấp các hàm tiện ích xử lý số.
/// Cộng thêm 1 vào số truyền vào.
pub fn cong_mot(x: i32) -> i32 {
x + 1
}
Chuẩn bị publish
Trước khi cargo publish, Cargo.toml cần đủ metadata bắt buộc:
[package]
name = "ten-crate-cua-ban"
version = "0.1.0"
edition = "2024"
description = "Mô tả ngắn gọn crate làm gì"
license = "MIT OR Apache-2.0"
Đăng nhập bằng API token lấy từ trang crates.io (cargo login <token>), rồi:
cargo publish
Phiên bản đã đăng là vĩnh viễn
Một phiên bản đã cargo publish không thể xoá hay ghi đè — đây là đảm bảo quan trọng cho mọi dự
án khác đang phụ thuộc vào đúng phiên bản đó: Cargo.lock của họ trỏ tới một mã nguồn cố định,
không bao giờ bị đổi ngầm dưới chân. Phát hiện lỗi sau khi publish thì phát hành bản vá ở phiên bản
mới hơn.
Nếu một phiên bản có vấn đề nghiêm trọng cần ngăn dự án mới phụ thuộc vào (nhưng không muốn phá
vỡ các dự án đã khoá cứng phiên bản đó), dùng cargo yank thay vì xoá:
cargo yank --vers 1.0.1
yank không xoá gì — chỉ ngăn các dự án mới chọn phiên bản đó khi thêm dependency lần đầu; dự
án đã có Cargo.lock khoá đúng phiên bản này từ trước vẫn build bình thường.
Workspace
Workspace gom nhiều package cùng chia sẻ một Cargo.lock và một thư mục target/ chung
— hữu ích khi một dự án tách thành nhiều crate liên quan (ví dụ một thư viện lõi và vài binary dùng
thư viện đó), thay vì quản lý rời rạc từng crate với dependency và bản build riêng.
Cấu trúc
workspace-cua-toi/
├── Cargo.toml
├── them_hai/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── them_mot/
├── Cargo.toml
└── src/
└── main.rs
Cargo.toml ở gốc chỉ khai báo các thành viên, không có phần [package] riêng (gọi là virtual
manifest — bản thân thư mục gốc không phải một crate, chỉ là nơi gom nhóm):
[workspace]
members = ["them_hai", "them_mot"]
Mỗi thành viên vẫn có Cargo.toml riêng như một package bình thường. Muốn một thành viên dùng crate
khác trong cùng workspace, khai báo dependency qua đường dẫn tương đối:
[dependencies]
them_hai = { path = "../them_hai" }
Lợi ích của Cargo.lock và target/ dùng chung
Toàn bộ workspace chia sẻ một Cargo.lock — mọi thành viên dùng cùng một phiên bản cho bất kỳ
dependency ngoài nào cả hai cùng cần, tránh tình huống hai crate trong cùng dự án âm thầm kéo hai
phiên bản khác nhau của cùng một thư viện (tốn công biên dịch hai lần, và có thể gây lỗi kiểu không
tương thích nếu thư viện đó có kiểu dữ liệu đi qua ranh giới giữa hai crate). Thư mục target/ dùng
chung cũng có nghĩa dependency chung giữa các thành viên chỉ biên dịch một lần, không lặp lại
cho mỗi crate.
Lệnh Cargo trong workspace
Chạy ở thư mục gốc workspace:
cargo build # build tất cả thành viên
cargo test # chạy test của tất cả thành viên
cargo run -p them_mot # chạy đúng binary của thành viên "them_mot"
Cờ -p <tên-package> (viết tắt của --package) nhắm một thành viên cụ thể — cần thiết khi
workspace có nhiều binary crate, vì Cargo không tự đoán được muốn chạy cái nào nếu không chỉ rõ.
Con trỏ thông minh
Con trỏ thông minh (smart pointer) là một struct hoạt động như con trỏ (trỏ tới dữ liệu ở chỗ
khác) nhưng mang thêm khả năng — thường implement Deref (dùng được như tham chiếu thường) và
Drop (tự dọn dẹp khi ra khỏi scope). String và Vec<T> thực chất cũng là con trỏ thông minh
theo định nghĩa này; chương này tập trung vào ba cái quan trọng nhất chưa gặp: Box<T>, Rc<T>,
RefCell<T>.
Box<T>
Box<T> là con trỏ thông minh đơn giản nhất: đặt một giá trị lên heap, giữ một con trỏ tới đó trên
stack — một chủ sở hữu duy nhất, không thêm chi phí nào khác ngoài một lượt gián tiếp qua con trỏ.
fn main() {
let b = Box::new(5);
println!("b = {b}");
}
Kiểu đệ quy cần Box
Một enum tự chứa chính nó (không qua con trỏ) khiến trình biên dịch không thể tính được kích thước cố định — kích thước của kiểu đó phụ thuộc vào chính nó, đệ quy vô hạn:
enum DanhSach {
Node(i32, DanhSach),
Rong,
}
fn main() {}
Box<T> có kích thước cố định (đúng bằng kích thước một con trỏ) bất kể T là gì — thay DanhSach
trực tiếp bằng Box<DanhSach> phá vỡ chuỗi đệ quy vô hạn về kích thước, vì giờ mỗi Node chỉ cần
chứa một con trỏ cỡ cố định, không phải toàn bộ phần còn lại của danh sách nằm inline:
enum DanhSach {
Node(i32, Box<DanhSach>),
Rong,
}
use DanhSach::{Node, Rong};
fn main() {
let ds = Node(1, Box::new(Node(2, Box::new(Node(3, Box::new(Rong))))));
let mut hien_tai = &ds;
while let Node(gia_tri, tiep_theo) = hien_tai {
println!("{gia_tri}");
hien_tai = tiep_theo;
}
}
Box<T> cũng hữu ích khi cần chuyển quyền sở hữu một giá trị lớn mà không muốn copy toàn bộ dữ liệu
— move một Box<T> chỉ move con trỏ (rẻ), dữ liệu thật trên heap không hề bị di chuyển.
Deref và Drop
Hai trait làm nên phần lớn “phép màu” khiến con trỏ thông minh dùng được tự nhiên như tham chiếu
thường: Deref (dùng * để lấy ra giá trị bên trong) và Drop (tự chạy dọn dẹp khi ra khỏi
scope).
Deref
fn main() {
let x = 5;
let y = Box::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}
*y hoạt động được vì Box<T> implement trait Deref. Viết một con trỏ thông minh của riêng mình
để thấy rõ cơ chế:
use std::ops::Deref;
struct HopCuaToi<T>(T);
impl<T> HopCuaToi<T> {
fn new(x: T) -> HopCuaToi<T> {
HopCuaToi(x)
}
}
impl<T> Deref for HopCuaToi<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
fn main() {
let x = 5;
let y = HopCuaToi::new(x);
assert_eq!(5, *y);
}
*y thực chất được trình biên dịch khai triển thành *(y.deref()) — gọi deref() để lấy một tham
chiếu &T, rồi giải tham chiếu tham chiếu đó. Chỉ cần implement đúng một method deref, toàn bộ cú
pháp * tự động hoạt động theo.
Deref coercion
Trình biên dịch tự động chèn thêm lời gọi deref() khi truyền một &HopCuaToi<T> vào chỗ đang cần
&T (hoặc &U nếu T cũng implement Deref sang U, áp dụng liên tiếp nhiều bước) — gọi là
deref coercion:
use std::ops::Deref;
struct HopCuaToi<T>(T);
impl<T> Deref for HopCuaToi<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
fn chao(ten: &str) {
println!("Xin chào, {ten}!");
}
fn main() {
let m = HopCuaToi(String::from("Rust"));
chao(&m);
}
&m có kiểu &HopCuaToi<String>, chao cần &str — trình biên dịch tự chèn hai bước deref
(&HopCuaToi<String> → &String → &str) để khớp, không cần người gọi tự viết &(*m)[..]. Đây là
lý do vì sao các hàm nhận &str gọi được cả với &String lẫn &Box<String> mà không cần ép kiểu
tường minh ở nơi gọi.
Drop
Drop chạy code dọn dẹp đúng một lần, tự động, khi giá trị ra khỏi scope — không cần gọi thủ công:
struct ThongBaoKhiDrop {
du_lieu: String,
}
impl Drop for ThongBaoKhiDrop {
fn drop(&mut self) {
println!("Đang dọn dẹp `{}`!", self.du_lieu);
}
}
fn main() {
let _a = ThongBaoKhiDrop {
du_lieu: String::from("cái đầu tiên"),
};
let _b = ThongBaoKhiDrop {
du_lieu: String::from("cái thứ hai"),
};
println!("Đã tạo xong các instance.");
}
Giá trị bị drop theo thứ tự ngược với thứ tự tạo (_b trước _a) — giống ngăn xếp, phần tử vào
sau ra trước.
Không thể tự tay gọi gia_tri.drop() — trình biên dịch cấm gọi trực tiếp method này, vì nếu cho
phép, giá trị đó vẫn sẽ bị drop thêm một lần nữa tự động khi hết scope, gây double free. Cần
drop sớm chủ động (ví dụ để giải phóng một khoá trước khi scope kết thúc), gọi hàm tự do
std::mem::drop(gia_tri) — nhận quyền sở hữu giá trị rồi thả ngay, không đụng tới method drop của
trait.
Rc và RefCell
Rc<T>: nhiều chủ sở hữu
Quy tắc quyền sở hữu từ đầu sách tới giờ luôn giả định một chủ sở hữu. Một số cấu trúc dữ liệu
cần nhiều phần code cùng hợp lệ sở hữu chung một giá trị — ví dụ nhiều node trong một cấu trúc dạng
đồ thị cùng tham chiếu tới một node dùng chung. Rc<T> (Reference Counted) cho phép điều đó bằng
cách đếm số chủ sở hữu hiện tại, chỉ drop dữ liệu thật khi đếm về 0:
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("dữ liệu dùng chung"));
println!("Đếm sau khi tạo a = {}", Rc::strong_count(&a));
let b = Rc::clone(&a);
println!("Đếm sau khi tạo b = {}", Rc::strong_count(&a));
{
let c = Rc::clone(&a);
println!("Đếm sau khi tạo c = {}", Rc::strong_count(&a));
}
println!("Đếm sau khi c ra khỏi scope = {}", Rc::strong_count(&a));
}
Rc::clone(&a) chỉ tăng bộ đếm tham chiếu — không copy dữ liệu bên trong. Quy ước dùng
Rc::clone(&a) thay vì a.clone() dù cả hai làm cùng một việc: cách viết tường minh giúp người đọc
nhận ra ngay đây là một lượt tăng đếm rẻ, không phải một deep clone tốn kém như .clone() thường
ngụ ý ở hầu hết kiểu khác. Rc<T> chỉ an toàn dùng trong ngữ cảnh đơn luồng — chia sẻ giữa
nhiều luồng cần Arc<T>, nói ở chương Concurrency.
RefCell<T>: khả biến nội tại
Borrow checker kiểm tra quy tắc mượn (một khả biến, hoặc nhiều bất biến) lúc biên dịch.
RefCell<T> chuyển việc kiểm tra đó sang lúc chạy — cho phép sửa dữ liệu ngay cả qua một tham
chiếu bất biến tới RefCell, đổi lại nếu vi phạm quy tắc mượn, chương trình panic lúc chạy thay
vì báo lỗi biên dịch:
use std::cell::RefCell;
fn main() {
let so = RefCell::new(5);
{
let mut so_mut = so.borrow_mut();
*so_mut += 10;
}
println!("{}", so.borrow());
}
borrow() trả về Ref<T> (như mượn bất biến), borrow_mut() trả RefMut<T> (như mượn khả biến) —
RefCell tự đếm số lượt mượn đang tồn tại lúc chạy, y hệt logic borrow checker vẫn làm, chỉ khác
thời điểm kiểm tra:
use std::cell::RefCell;
fn main() {
let so = RefCell::new(5);
let _muon_1 = so.borrow_mut();
let _muon_2 = so.borrow_mut(); // panic lúc chạy: đã có một mượn khả biến khác
println!("sẽ không chạy tới đây");
}
Đánh đổi này có ý nghĩa trong tình huống cụ thể: một giá trị nên hiện ra bất biến với code gọi
nó (giữ đúng hợp đồng API), nhưng nội bộ cần tự sửa một trạng thái (ví dụ bộ đếm số lần gọi để phục
vụ test, hay một cache). Trình biên dịch không đủ thông tin tĩnh để xác nhận cách dùng đó luôn an
toàn, nhưng người viết code biết chắc theo logic chương trình — RefCell cho phép thực hiện điều
đó, trả giá bằng khả năng panic nếu logic đó sai.
Kết hợp Rc<RefCell<T>>
Nhiều chủ sở hữu (Rc) và mỗi chủ sở hữu đều sửa được dữ liệu dùng chung (RefCell) là một tổ hợp
rất phổ biến trong code đơn luồng:
use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let gia_tri_chung = Rc::new(RefCell::new(5));
let a = Rc::clone(&gia_tri_chung);
let b = Rc::clone(&gia_tri_chung);
*a.borrow_mut() += 10;
*b.borrow_mut() += 100;
println!("Giá trị cuối: {}", gia_tri_chung.borrow());
}
Chu trình tham chiếu
Rc<T> tự dọn dẹp dữ liệu khi bộ đếm về 0 — nhưng nếu hai giá trị Rc trỏ vòng lại chính nhau, bộ
đếm của cả hai không bao giờ về 0, dữ liệu rò rỉ vĩnh viễn dù không còn ai truy cập được tới nó
nữa. Đây không phải lỗi vi phạm an toàn bộ nhớ (không có use-after-free hay double-free) nên borrow
checker không ngăn được — chỉ là một lỗi logic, và Rust không hứa hẹn ngăn mọi rò rỉ bộ nhớ, chỉ
ngăn các lỗi bộ nhớ không an toàn.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
tiep_theo: RefCell<Option<Rc<Node>>>,
}
fn main() {
let a = Rc::new(Node {
tiep_theo: RefCell::new(None),
});
let b = Rc::new(Node {
tiep_theo: RefCell::new(Some(Rc::clone(&a))),
});
// Tạo chu trình: a trỏ tới b, b đã trỏ tới a từ trước
*a.tiep_theo.borrow_mut() = Some(Rc::clone(&b));
println!(
"đếm a = {}, đếm b = {}",
Rc::strong_count(&a),
Rc::strong_count(&b)
);
}
Sau đoạn trên, cả a và b đều có strong_count là 2 (một từ biến cục bộ, một từ tham chiếu vòng
của phía kia) — không bao giờ giảm về 0 dù a, b đã ra khỏi scope, vì mỗi bên vẫn giữ một Rc
trỏ tới bên còn lại.
Weak<T>: tham chiếu không sở hữu
Rc::downgrade(&a) tạo một Weak<T> — tham chiếu không tính vào strong_count, nên không ngăn
dữ liệu bị drop. Vì dữ liệu có thể đã bị drop mất bất cứ lúc nào, Weak<T> không cho truy cập trực
tiếp — phải gọi .upgrade(), trả về Option<Rc<T>>: Some nếu dữ liệu vẫn còn, None nếu đã bị
drop.
Ứng dụng điển hình: quan hệ cha-con, nơi cha sở hữu con nhưng con chỉ cần tham chiếu ngược tới cha, không sở hữu:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Node {
gia_tri: i32,
cha: RefCell<Weak<Node>>,
con: RefCell<Vec<Rc<Node>>>,
}
fn main() {
let la = Rc::new(Node {
gia_tri: 3,
cha: RefCell::new(Weak::new()),
con: RefCell::new(vec![]),
});
let goc = Rc::new(Node {
gia_tri: 5,
cha: RefCell::new(Weak::new()),
con: RefCell::new(vec![Rc::clone(&la)]),
});
*la.cha.borrow_mut() = Rc::downgrade(&goc);
match la.cha.borrow().upgrade() {
Some(cha) => println!("Giá trị của cha: {}", cha.gia_tri),
None => println!("Không còn cha"),
}
}
goc sở hữu la qua Rc trong con (quan hệ sở hữu thật: cha giữ con sống); la chỉ giữ Weak
tới goc trong cha — không tạo thành chu trình sở hữu, vì Weak không góp phần giữ goc sống.
Chọn hướng nào là Rc (sở hữu thật), hướng nào là Weak (chỉ tham chiếu) tuỳ ngữ nghĩa dữ liệu:
hướng “cha sở hữu con” hợp lý về mặt vòng đời (con không nên tồn tại nếu cha đã mất), hướng ngược
lại chỉ cần biết “cha là ai” mà không ảnh hưởng gì tới việc cha có tồn tại hay không.
Concurrency không sợ hãi
Chạy nhiều đoạn code độc lập cùng lúc luôn tiềm ẩn một lớp lỗi đặc trưng: hai luồng cùng đọc/ghi một dữ liệu mà không đồng bộ (data race), thứ tự thực thi không cố định gây kết quả khác nhau giữa các lần chạy, deadlock giữa nhiều khoá. Rust không loại bỏ được sự phức tạp cố hữu của lập trình đồng thời, nhưng tái dùng đúng hệ thống quyền sở hữu/mượn đã học để bắt phần lớn lỗi đó tại thời điểm biên dịch — đây là ý nghĩa của “concurrency không sợ hãi”: lỗi đồng thời phổ biến trở thành lỗi biên dịch thay vì bug ẩn chỉ lộ ra ngẫu nhiên lúc chạy.
Thread
thread::spawn chạy một closure trên một luồng hệ điều hành mới, song song với luồng gọi nó:
use std::thread;
use std::time::Duration;
fn main() {
let handle = thread::spawn(|| {
for i in 1..3 {
println!("số {i} từ luồng con");
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..3 {
println!("số {i} từ luồng chính");
}
handle.join().unwrap();
}
thread::spawn trả về một JoinHandle — .join() chặn luồng gọi nó lại cho tới khi luồng kia chạy
xong. Thiếu .join(), chương trình có thể kết thúc (luồng main chạy xong, tiến trình thoát) trước
khi luồng con kịp hoàn thành — không có gì đảm bảo luồng con luôn chạy xong nếu không chờ tường
minh.
Vì sao closure truyền vào thread::spawn thường cần move
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(|| {
println!("Vector: {v:?}");
});
handle.join().unwrap();
}
Lỗi biên dịch: trình biên dịch không biết luồng con sẽ chạy lâu bao lâu so với luồng main — nếu
closure chỉ mượn v, không gì đảm bảo v còn sống đủ lâu để luồng con đọc nó (main có thể drop
v trước khi luồng con kịp chạy tới dòng đó). Thêm move ép closure lấy quyền sở hữu v, loại bỏ
hẳn khả năng đó:
use std::thread;
fn main() {
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Vector: {v:?}");
});
handle.join().unwrap();
}
Message passing
Một cách phổ biến để các luồng phối hợp an toàn: thay vì cùng truy cập chung một vùng dữ liệu, chúng gửi giá trị cho nhau qua một kênh (channel) — “không chia sẻ bộ nhớ để giao tiếp, mà giao tiếp bằng cách chuyển quyền sở hữu qua lại”.
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let gia_tri = String::from("xin chào");
tx.send(gia_tri).unwrap();
});
let nhan_duoc = rx.recv().unwrap();
println!("Nhận được: {nhan_duoc}");
}
mpsc::channel() (multiple producer, single consumer) trả về cặp (Sender, Receiver). tx.send(v)
move quyền sở hữu v vào kênh — đây là điểm mấu chốt: sau khi gửi, luồng gửi không còn quyền
truy cập v nữa, nên không thể có chuyện cả luồng gửi lẫn luồng nhận cùng đọc/ghi cùng một giá
trị cùng lúc. Borrow checker biến quy tắc thiết kế “đừng chia sẻ dữ liệu đang gửi” thành một ràng
buộc bắt buộc, không phải quy ước hi vọng người viết code tự tuân theo:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let gia_tri = String::from("xin chào");
tx.send(gia_tri).unwrap();
println!("vẫn dùng gia_tri: {gia_tri}"); // lỗi: gia_tri đã bị move vào send
});
println!("{}", rx.recv().unwrap());
}
rx.recv() chặn luồng gọi tới khi có giá trị, trả Result — Err khi mọi Sender đã bị drop (sẽ
không bao giờ có giá trị mới gửi tới nữa). Có thể duyệt rx trực tiếp như iterator, tự động chặn
chờ ở mỗi lần lặp và kết thúc khi kênh đóng:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let cac_gia_tri = vec![
String::from("xin"),
String::from("chào"),
String::from("từ"),
String::from("luồng con"),
];
for gia_tri in cac_gia_tri {
tx.send(gia_tri).unwrap();
thread::sleep(Duration::from_millis(1));
}
});
for nhan_duoc in rx {
println!("Nhận được: {nhan_duoc}");
}
}
Nhiều luồng gửi
Sender implement Clone — nhân bản để nhiều luồng cùng gửi vào một Receiver:
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
let tx2 = tx.clone();
thread::spawn(move || {
tx.send(String::from("từ luồng 1")).unwrap();
});
thread::spawn(move || {
tx2.send(String::from("từ luồng 2")).unwrap();
});
for nhan_duoc in rx {
println!("Nhận được: {nhan_duoc}");
}
}
Shared state
Message passing tránh chia sẻ trực tiếp, nhưng đôi khi nhiều luồng thật sự cần cùng đọc/ghi một vùng
dữ liệu — ví dụ một bộ đếm dùng chung. Mutex<T> (mutual exclusion) cho phép điều đó: tại một thời
điểm, chỉ đúng một luồng được quyền truy cập dữ liệu bên trong.
use std::sync::Mutex;
fn main() {
let m = Mutex::new(5);
{
let mut so = m.lock().unwrap();
*so += 1;
}
println!("m = {m:?}");
}
m.lock() chặn luồng gọi cho tới khi giành được khoá, trả Result<MutexGuard<T>, _> — Err khi
một luồng khác đã panic trong lúc giữ khoá đó (gọi là “poisoned”, tránh việc luồng sau nhận một
trạng thái dữ liệu có thể đã dở dang). MutexGuard<T> implement Deref (dùng *so để truy cập dữ
liệu, giống Ref/RefMut của RefCell ở chương trước) và Drop — khoá tự động nhả ngay khi
MutexGuard ra khỏi scope, không cần tự tay gọi hàm mở khoá.
Arc<T>: Rc an toàn giữa nhiều luồng
Chia sẻ một Mutex<T> giữa nhiều luồng cần nhiều chủ sở hữu — nhưng Rc<T> (chương Con trỏ thông
minh) không an toàn giữa nhiều luồng (bộ đếm của nó không đồng bộ hoá, hai luồng cùng tăng/giảm đếm
đồng thời chính là một data race). Arc<T> (Atomic Reference Counted) làm y hệt việc Rc<T> làm,
nhưng dùng phép toán đếm an toàn giữa nhiều luồng — đánh đổi lấy một chi phí nhỏ lúc chạy so với
Rc<T>, nên chỉ dùng Arc khi thật sự cần chia sẻ giữa nhiều luồng:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let dem = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let dem = Arc::clone(&dem);
let handle = thread::spawn(move || {
let mut gia_tri = dem.lock().unwrap();
*gia_tri += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Kết quả: {}", *dem.lock().unwrap());
}
Arc<Mutex<T>> song song hoàn toàn với Rc<RefCell<T>> đã gặp ở chương trước — cùng một ý tưởng
(nhiều chủ sở hữu cùng có thể sửa dữ liệu dùng chung), chỉ khác Arc/Mutex là phiên bản an toàn
giữa nhiều luồng, còn Rc/RefCell chỉ dùng được trong một luồng. Rủi ro cũng đổi khác tương ứng:
dùng sai RefCell gây panic lúc chạy; dùng sai Mutex (ví dụ hai khoá lồng nhau theo thứ tự ngược
nhau ở hai luồng) có thể gây deadlock — cả hai luồng cùng chờ nhau vĩnh viễn. Rust không phát
hiện được deadlock tại thời điểm biên dịch, đây vẫn là trách nhiệm thiết kế của người viết code.
Send và Sync
Hai trait đánh dấu (marker trait — không có method nào, chỉ tồn tại để gắn nhãn cho kiểu) là nền
tảng thật sự khiến “concurrency không sợ hãi” thành hiện thực: Send và Sync. Phần lớn thời gian
không cần biết tới chúng, vì trình biên dịch tự động implement cả hai cho mọi kiểu cấu thành
hoàn toàn từ các phần đã là Send/Sync — nhưng chính hai trait này là lý do những lỗi như “dùng
Rc giữa nhiều luồng” bị chặn ngay lúc biên dịch ở chương trước.
Send: chuyển quyền sở hữu qua luồng khác an toàn
Một kiểu implement Send nếu quyền sở hữu giá trị của nó chuyển được sang một luồng khác an
toàn — hầu hết kiểu trong Rust là Send. Rc<T> là ngoại lệ đáng chú ý: không implement
Send, vì bộ đếm bên trong nó không đồng bộ hoá — chuyển một Rc sang luồng khác trong khi luồng
gốc vẫn giữ bản sao khác sẽ mở đường cho hai luồng cùng tăng/giảm đếm không an toàn. Đây chính xác
là lý do thread::spawn đòi hỏi closure phải 'static và mọi giá trị move vào phải Send — cố
move một Rc<T> vào một closure cho thread::spawn là lỗi biên dịch, không phải lỗi runtime ẩn.
Sync: chia sẻ tham chiếu qua luồng khác an toàn
Một kiểu implement Sync nếu &T (tham chiếu bất biến tới nó) an toàn để nhiều luồng cùng cầm —
nói cách khác, T là Sync khi và chỉ khi &T là Send. Mutex<T> là Sync (đó là toàn bộ lý
do nó tồn tại — tự đồng bộ hoá truy cập). RefCell<T> thì không — nó chỉ kiểm tra quy tắc mượn
lúc chạy trong ngữ cảnh một luồng duy nhất, không có cơ chế đồng bộ nào chống lại hai luồng cùng gọi
borrow_mut() đồng thời. Đây là lý do chia sẻ trạng thái khả biến giữa các luồng cần Mutex<T>,
chứ không dùng lại được RefCell<T>.
Vì sao điều này quan trọng
Send và Sync biến “kiểu dữ liệu này có an toàn để dùng giữa nhiều luồng hay không” từ một câu
hỏi phải tự nhớ và kiểm tra thủ công, thành một thuộc tính của hệ thống kiểu mà trình biên dịch
tự kiểm tra ở mọi nơi kiểu đó được dùng — cùng triết lý với quyền sở hữu và borrow checker: thay vì
tài liệu hoá quy tắc rồi hy vọng không ai viết sai, mã hoá thẳng quy tắc đó vào kiểu để trình biên
dịch từ chối biên dịch code vi phạm.
Cả hai trait đều tự động derive — không tự tay implement cho kiểu của mình trong phần lớn trường
hợp, trừ khi đang viết code cấp thấp dùng unsafe để tự quản lý một thứ vốn không an toàn giữa
nhiều luồng theo mặc định (nói ở
chương Tính năng nâng cao) — tự implement Send/Sync
thủ công là một cam kết “tôi tự đảm bảo điều này an toàn”, trình biên dịch không tự kiểm tra giúp
được nữa một khi đã dùng unsafe impl.
Lập trình bất đồng bộ
Thread (chương trước) phù hợp cho công việc chiếm dụng CPU thật sự, chạy song song. Nhiều tác vụ thực tế lại chủ yếu chờ — chờ phản hồi mạng, chờ đọc đĩa — mà một luồng OS dành riêng cho mỗi tác vụ chờ như vậy khá lãng phí (mỗi luồng OS tốn bộ nhớ stack riêng, tốn chi phí chuyển ngữ cảnh). Async giải quyết đúng bài toán đó: chạy hàng nghìn tác vụ đang chờ cùng lúc trên một số lượng nhỏ luồng thật, bằng cách tạm dừng một tác vụ đang chờ và nhường chỗ cho tác vụ khác chạy tiếp.
Future: một giá trị sẽ có sau
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
Future chỉ có một method: poll, trả Poll::Ready(gia_tri) nếu đã xong, Poll::Pending nếu chưa
— và tự nó không làm gì cả nếu không ai gọi poll. Đây là điểm khác biệt cốt lõi so với luồng
(thread): một luồng OS tự chạy ngay khi được tạo; một Future chỉ là một mô tả “công việc sẽ làm”,
cần một bên khác (executor) liên tục gọi poll cho tới khi Ready.
async fn/khối async {} là đường cú pháp: trình biên dịch tự biến phần thân thành một kiểu vô
danh implement Future, mỗi điểm .await là một chỗ có thể tạm dừng (trả Pending) và tiếp tục
đúng chỗ đó ở lượt poll sau — không cần tự tay viết poll bằng tay trong phần lớn trường hợp.
std không có sẵn executor
Khác thread::spawn (chạy được ngay vì luồng OS là tài nguyên hệ điều hành cấp sẵn), thư viện chuẩn
Rust chỉ định nghĩa trait Future và cú pháp async/.await — không đi kèm executor nào để
thật sự chạy chúng. Trong dự án thực tế, việc chạy future dựa vào một crate runtime ngoài (phổ biến
nhất: tokio, async-std). Để thấy rõ bản chất cơ chế polling mà không phụ thuộc crate ngoài, viết
một executor tối giản chỉ bằng thư viện chuẩn:
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct WakerKhongLamGi;
impl Wake for WakerKhongLamGi {
fn wake(self: Arc<Self>) {}
}
fn chay<F: Future>(future: F) -> F::Output {
let mut future = Box::pin(future);
let waker = Waker::from(Arc::new(WakerKhongLamGi));
let mut cx = Context::from_waker(&waker);
loop {
match future.as_mut().poll(&mut cx) {
Poll::Ready(gia_tri) => return gia_tri,
Poll::Pending => {}
}
}
}
async fn chao() -> i32 {
42
}
fn main() {
println!("{}", chay(chao()));
}
chay liên tục gọi poll trong một vòng lặp cho tới khi Ready — một executor thật không lặp bận
(busy-loop) như vậy: nó dùng Waker để được báo lại đúng lúc future sẵn sàng poll tiếp (ví dụ
hệ điều hành báo dữ liệu mạng đã tới), rồi ngủ chờ giữa các lần đó thay vì poll liên tục tốn CPU.
WakerKhongLamGi ở trên bỏ qua hoàn toàn cơ chế đó để giữ ví dụ tối giản.
Xem rõ vòng lặp poll hoạt động khi một future thật sự trả Pending trước khi Ready:
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct ChoMotLuotPoll {
da_poll: bool,
}
impl Future for ChoMotLuotPoll {
type Output = &'static str;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.da_poll {
Poll::Ready("xong")
} else {
self.da_poll = true;
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
struct WakerKhongLamGi;
impl Wake for WakerKhongLamGi {
fn wake(self: Arc<Self>) {}
}
fn chay<F: Future>(future: F) -> F::Output {
let mut future = Box::pin(future);
let waker = Waker::from(Arc::new(WakerKhongLamGi));
let mut cx = Context::from_waker(&waker);
let mut so_lan_poll = 0;
loop {
so_lan_poll += 1;
match future.as_mut().poll(&mut cx) {
Poll::Ready(gia_tri) => {
println!("Đã Ready sau {so_lan_poll} lượt poll");
return gia_tri;
}
Poll::Pending => {}
}
}
}
fn main() {
let ket_qua = chay(ChoMotLuotPoll { da_poll: false });
println!("{ket_qua}");
}
ChoMotLuotPoll trả Pending ở lượt poll đầu, Ready ở lượt thứ hai — implement poll thủ công
chính xác là điều một khối async {} làm giúp một cách tự động và phức tạp hơn nhiều (theo dõi được
tạm dừng ở .await nào, khôi phục đúng chỗ đó ở lượt sau), nhưng bản chất cơ chế underneath là như
nhau.
Concurrency với async
.await tuần tự không tự nhiên đồng thời
Nhiều điểm .await liên tiếp trong cùng một async fn chạy tuần tự — điểm thứ hai chỉ bắt đầu
sau khi điểm thứ nhất đã Ready hoàn toàn, giống hệt gọi hàm thường nối tiếp nhau. Tự bản thân
async/.await không tạo ra tính đồng thời — nó chỉ là cách viết một future tạm dừng được ở những
chỗ đang chờ, không phải phép màu tự động chạy song song mọi thứ có .await.
Chạy nhiều future đồng thời
Muốn nhiều future thật sự tiến triển xen kẽ nhau (không cái nào phải xong hẳn mới tới lượt cái tiếp theo), cần một trong hai cách:
- spawn mỗi future thành một task riêng trên runtime — bộ lập lịch của runtime tự luân phiên poll từng task độc lập.
- gộp nhiều future vào một tổ hợp poll cùng nhau trong một task duy nhất, thường qua macro
join!/select!.
Cả hai cơ chế này đều không có trong thư viện chuẩn — chúng do crate runtime (tokio,
async-std, futures) cung cấp, vì việc điều phối nhiều future đúng đắn (đặc biệt là poll đan xen
an toàn, không vi phạm quy tắc Pin) là một bài toán runtime cần giải quyết trọn vẹn, không phải
thứ std tự làm thay. Với một runtime như tokio, code trông như sau (minh hoạ cú pháp, không chạy
được nếu thiếu crate tokio):
use tokio::join;
async fn tai_trang(url: &str) -> String {
// giả lập gọi mạng
format!("nội dung của {url}")
}
async fn tai_ca_hai() {
let (a, b) = join!(tai_trang("a.example"), tai_trang("b.example"));
println!("{a} — {b}");
}
join! chạy cả hai future đồng thời, chỉ trả về khi cả hai đã Ready — khác hẳn viết
tai_trang("a...").await; tai_trang("b...").await; (tuần tự, cái thứ hai đợi cái thứ nhất xong hẳn
mới bắt đầu).
select! chạy nhiều future đồng thời nhưng trả về ngay khi future đầu tiên hoàn thành, huỷ các
future còn lại — hữu ích cho các tình huống như đặt timeout (chạy đua giữa “thao tác chính” và “đồng
hồ đếm giờ”, cái nào xong trước thắng):
use tokio::select;
use tokio::time::{sleep, Duration};
async fn vi_du_timeout() {
select! {
ket_qua = tai_du_lieu() => println!("Xong: {ket_qua}"),
_ = sleep(Duration::from_secs(5)) => println!("Hết thời gian chờ"),
}
}
async fn tai_du_lieu() -> String {
String::from("dữ liệu")
}
Stream
Iterator cho một chuỗi giá trị lấy được ngay lập tức, từng bước; Stream là phiên bản bất đồng
bộ của ý tưởng đó — một chuỗi giá trị mà mỗi bước lấy tiếp theo có thể phải chờ (một gói tin
mạng, một dòng dữ liệu đọc dần từ đĩa), tương ứng poll_next trả Poll::Pending khi phần tử kế
tiếp chưa sẵn sàng, Poll::Ready(Some(gia_tri)) khi có, Poll::Ready(None) khi hết:
trait Stream {
type Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>>;
}
Trait Stream chưa ổn định trong thư viện chuẩn tại thời điểm viết sách này — dùng qua crate
ngoài (futures::stream::Stream, hoặc tương đương do runtime cung cấp), tương tự tình huống
Future tự chạy được cần một executor ngoài std.
Duyệt qua Stream
Runtime/crate cung cấp method tiện dụng để duyệt Stream gần giống for với Iterator, chỉ khác mỗi
bước cần .await:
use futures::stream::{self, StreamExt};
async fn vi_du() {
let mut stream = stream::iter(vec![1, 2, 3]);
while let Some(gia_tri) = stream.next().await {
println!("{gia_tri}");
}
}
Kết hợp adaptor như Iterator
Stream hỗ trợ các adaptor quen thuộc từ Iterator (map, filter, take, …) — vẫn giữ nguyên
tính lười: một chuỗi .map(...).filter(...) trên Stream không tự chạy gì cho tới khi có thứ gì đó
thật sự .await từng phần tử ra, y hệt tinh thần lười của Iterator đã học ở chương Closure và
Iterator, chỉ khác mỗi bước giờ có thể tạm dừng chờ thay vì luôn có sẵn ngay:
use futures::stream::{self, StreamExt};
async fn vi_du() {
let tong: i32 = stream::iter(vec![1, 2, 3, 4])
.map(|x| x * 2)
.filter(|x| {
let giu_lai = *x > 2;
async move { giu_lai }
})
.fold(0, |tong, x| async move { tong + x })
.await;
println!("{tong}");
}
Cùng một tư duy xử lý dữ liệu theo chuỗi phép biến đổi khai báo được (declarative) như Iterator,
mở rộng sang ngữ cảnh mỗi bước có thể cần chờ — không phải một mô hình tư duy hoàn toàn mới.
Trait object và hướng đối tượng
Vấn đề: một collection chứa nhiều kiểu cụ thể khác nhau
Vec<T> chỉ chứa được một T. Generic (chương Generic, Trait, Lifetime) cũng chỉ chọn một kiểu
cụ thể tại mỗi lần dùng — không giúp được khi cần một danh sách các thành phần giao diện, mỗi phần
tử là một kiểu cụ thể khác nhau, nhưng tất cả cùng cần vẽ được:
pub trait Ve {
fn ve(&self);
}
pub struct Nut {
pub nhan: String,
}
impl Ve for Nut {
fn ve(&self) {
println!("Vẽ nút bấm: {}", self.nhan);
}
}
pub struct O {
pub da_chon: bool,
}
impl Ve for O {
fn ve(&self) {
println!("Vẽ ô chọn, đã chọn: {}", self.da_chon);
}
}
fn main() {
let cac_thanh_phan: Vec<Box<dyn Ve>> = vec![
Box::new(Nut {
nhan: String::from("Đồng ý"),
}),
Box::new(O { da_chon: true }),
];
for tp in cac_thanh_phan.iter() {
tp.ve();
}
}
Box<dyn Ve> là một trait object: một con trỏ tới dữ liệu (Box) cộng một con trỏ tới bảng
phương thức (vtable) của trait Ve ứng với đúng kiểu cụ thể đang nằm trong Box đó. Kiểu
Vec<Box<dyn Ve>> chứa được Nut lẫn O cùng lúc — điều Vec<T> với T cố định không bao giờ
làm được.
Static dispatch và dynamic dispatch
Generic (từ chương trước) dùng static dispatch: trình biên dịch biết chính xác kiểu cụ thể ngay lúc biên dịch (nhờ monomorphization), gọi thẳng đúng hàm — không có gì phải tra cứu lúc chạy, nhưng đổi lại một collection generic chỉ chứa được một kiểu cụ thể.
Trait object dùng dynamic dispatch: kiểu cụ thể đằng sau dyn Ve chỉ biết được lúc chạy, nên
gọi tp.ve() phải tra vtable để tìm đúng hàm ve của kiểu cụ thể đang nằm ở đó — thêm một lượt tra
cứu gián tiếp so với static dispatch, và trình biên dịch cũng mất khả năng inline lời gọi đó (vì
không biết trước sẽ gọi hàm nào). Đổi lại, có được thứ static dispatch không thể: một collection
chứa nhiều kiểu cụ thể khác nhau, miễn cùng implement một trait.
Đây là đánh đổi tường minh giữa hai công cụ giải quyết hai vấn đề khác nhau: cần hiệu năng tối đa và chỉ một kiểu cụ thể tại một chỗ — dùng generic; cần chứa nhiều kiểu cụ thể không biết trước hết ngay lúc viết code — dùng trait object.
Không phải trait nào cũng thành trait object được
Một trait chỉ an toàn cho object (object-safe) nếu mọi method của nó không trả về Self và
không có tham số kiểu generic — cả hai điều đó đòi hỏi biết kiểu cụ thể ngay lúc biên dịch, mâu
thuẫn với bản chất “kiểu cụ thể chỉ biết lúc chạy” của trait object. Cố tạo dyn từ một trait không
thoả điều kiện này là lỗi biên dịch, kèm gợi ý rõ ràng trait đó “không thể trở thành object”.
Design pattern hướng đối tượng
Rust không có kế thừa giữa các kiểu (một struct “kế thừa” field/method của struct khác) — chia sẻ
hành vi dùng trait (định nghĩa chung, mỗi kiểu tự implement hoặc dùng bản mặc định) kết hợp
composition (một struct chứa struct khác làm field) thay cho kế thừa. Nhiều design pattern hướng đối
tượng kinh điển vẫn áp dụng được, chỉ đổi cách hiện thực cho hợp với mô hình trait + composition
này. Ví dụ tiêu biểu: state pattern — hành vi của một giá trị thay đổi tuỳ trạng thái nội tại,
không cần if kiểm tra trạng thái rải rác khắp nơi.
Ví dụ: quy trình xuất bản bài viết
Một bài viết đi qua ba trạng thái: Nháp → Chờ duyệt → Đã xuất bản, chỉ hiển thị nội dung
khi đã xuất bản. Mỗi trạng thái là một kiểu implement chung một trait TrangThai, giữ trong bài
viết qua một trait object:
trait TrangThai {
fn yeu_cau_duyet(self: Box<Self>) -> Box<dyn TrangThai>;
fn duyet(self: Box<Self>) -> Box<dyn TrangThai>;
fn noi_dung<'a>(&self, _bai_viet: &'a BaiViet) -> &'a str {
""
}
}
struct Nhap;
impl TrangThai for Nhap {
fn yeu_cau_duyet(self: Box<Self>) -> Box<dyn TrangThai> {
Box::new(ChoDuyet)
}
fn duyet(self: Box<Self>) -> Box<dyn TrangThai> {
self // chưa qua chờ duyệt thì duyệt không có tác dụng
}
}
struct ChoDuyet;
impl TrangThai for ChoDuyet {
fn yeu_cau_duyet(self: Box<Self>) -> Box<dyn TrangThai> {
self // đã ở trạng thái chờ duyệt, yêu cầu lại không đổi gì
}
fn duyet(self: Box<Self>) -> Box<dyn TrangThai> {
Box::new(DaXuatBan)
}
}
struct DaXuatBan;
impl TrangThai for DaXuatBan {
fn yeu_cau_duyet(self: Box<Self>) -> Box<dyn TrangThai> {
self
}
fn duyet(self: Box<Self>) -> Box<dyn TrangThai> {
self
}
fn noi_dung<'a>(&self, bai_viet: &'a BaiViet) -> &'a str {
&bai_viet.noi_dung
}
}
struct BaiViet {
trang_thai: Option<Box<dyn TrangThai>>,
noi_dung: String,
}
impl BaiViet {
fn moi() -> BaiViet {
BaiViet {
trang_thai: Some(Box::new(Nhap)),
noi_dung: String::new(),
}
}
fn them_noi_dung(&mut self, text: &str) {
self.noi_dung.push_str(text);
}
fn noi_dung(&self) -> &str {
self.trang_thai.as_ref().unwrap().noi_dung(self)
}
fn yeu_cau_duyet(&mut self) {
if let Some(s) = self.trang_thai.take() {
self.trang_thai = Some(s.yeu_cau_duyet());
}
}
fn duyet(&mut self) {
if let Some(s) = self.trang_thai.take() {
self.trang_thai = Some(s.duyet());
}
}
}
fn main() {
let mut bai_viet = BaiViet::moi();
bai_viet.them_noi_dung("Hôm nay trời đẹp");
assert_eq!("", bai_viet.noi_dung());
bai_viet.yeu_cau_duyet();
assert_eq!("", bai_viet.noi_dung());
bai_viet.duyet();
assert_eq!("Hôm nay trời đẹp", bai_viet.noi_dung());
println!("{}", bai_viet.noi_dung());
}
Vài điểm đáng chú ý trong cách hiện thực:
- Method
yeu_cau_duyet/duyetnhậnself: Box<Self>— tiêu thụ hẳn trạng thái cũ, trả về trạng thái mới, thay vì sửa tại chỗ. Chuyển trạng thái ở đây là thay hẳn một giá trị bằng giá trị khác, không phải chỉnh sửa cờ nội bộ của một giá trị duy nhất. trang_thaitrongBaiVietlàOption<Box<dyn TrangThai>>chứ không phảiBox<dyn TrangThai>trần — vì method tiêu thụself: Box<Self>, phải tạm thời lấy nó ra khỏiBaiViet(take()) mới chuyển được vào lời gọi, rồi gán trạng thái mới trả về ngược lại.Optionchỉ đóng vai trò một chỗ chứa tạm để làm việc đó hợp lệ với borrow checker, không mang ý nghĩa “bài viết có thể không có trạng thái”.- Toàn bộ logic “trạng thái nào cho phép chuyển sang trạng thái nào” nằm gọn trong từng impl của
từng trạng thái — thêm một trạng thái mới không đụng gì tới code của
BaiViethay các trạng thái khác, chỉ cần viết đúng traitTrangThaicho trạng thái mới đó.
Cách tổ chức này đánh đổi một chút phức tạp cú pháp (Box<Self>, Option tạm) để đổi lấy: không có
match/if kiểm tra “đang ở trạng thái gì” rải rác khắp BaiViet — mỗi nơi chỉ cần gọi method,
hành vi đúng tự động tới từ trạng thái cụ thể đang được trỏ tới.
Pattern nâng cao
Pattern (mẫu) là cú pháp để so khớp cấu trúc của một giá trị, đồng thời lấy phần cần thiết ra
thành biến. match (chương Enum) là nơi dùng pattern rõ ràng nhất, nhưng không phải nơi duy nhất —
chương này hệ thống lại toàn bộ chỗ pattern xuất hiện và các cú pháp pattern chưa gặp.
Nơi dùng pattern
let cũng là một pattern. let x = 5; thực chất là so khớp giá trị 5 với pattern x —
pattern đơn giản nhất, một tên biến, luôn khớp với bất kỳ giá trị nào. Pattern phức tạp hơn
destructure ngay tại let:
fn main() {
let (x, y, z) = (1, 2, 3);
println!("{x} {y} {z}");
}
Tham số hàm cũng là pattern, destructure được ngay trong danh sách tham số:
fn in_toa_do(&(x, y): &(i32, i32)) {
println!("Toạ độ: ({x}, {y})");
}
fn main() {
let diem = (3, 5);
in_toa_do(&diem);
}
for dùng pattern cho biến lặp — (chi_so, gia_tri) destructure trực tiếp tuple mà
.enumerate() trả ra:
fn main() {
let v = vec!['a', 'b', 'c'];
for (chi_so, gia_tri) in v.iter().enumerate() {
println!("{chi_so}: {gia_tri}");
}
}
if let/while let (đã gặp ở chương Enum) cũng dùng pattern, nhưng khác let/for/tham số hàm ở
một điểm quan trọng — nói ở phần tiếp theo.
Refutability
Pattern chia làm hai loại theo khả năng không khớp:
- Bất khả bác (irrefutable): khớp với mọi giá trị có thể có, không bao giờ thất bại. Một tên
biến trần (
x) là bất khả bác — bất kỳ giá trị nào gán cholet x = ...cũng khớp. - Khả bác (refutable): có ít nhất một giá trị khiến nó không khớp.
Some(x)là khả bác — nó không khớp nếu giá trị thực tế làNone.
let, tham số hàm, và for chỉ chấp nhận pattern bất khả bác — hợp lý, vì cả ba đều không có
cơ chế “làm gì đó nếu không khớp”, nên một pattern có thể thất bại ở đó sẽ không biết phải xử lý ra
sao:
fn main() {
let mot_so: Option<i32> = Some(5);
let Some(x) = mot_so; // lỗi: `Some(x)` khả bác, mot_so có thể là None
println!("{x}");
}
if let/while let ngược lại, chỉ nhận pattern khả bác mới có ý nghĩa — đó chính là lý do
chúng tồn tại: chạy một nhánh code khi khớp, bỏ qua khi không. Dùng chúng với một pattern bất khả
bác vẫn biên dịch được nhưng vô nghĩa (luôn khớp, không cần điều kiện), trình biên dịch cảnh báo
thay vì báo lỗi:
fn main() {
let x = 5;
if let x = x {
println!("Pattern này luôn khớp: {x}");
}
}
let-else (chương Enum) là cách dùng một pattern khả bác ở vị trí giống let, nhưng vẫn hợp lệ
nhờ buộc nhánh không khớp phải rẽ hẳn khỏi luồng chính (return/break/panic!) — nhờ vậy phần
còn lại của hàm vẫn luôn chạy với giả định pattern đã khớp, giữ được tính bất khả bác cho phần
code phía sau let-else, dù bản thân pattern viết ra là khả bác.
Cú pháp pattern
Khớp literal, nhiều pattern, khoảng giá trị
fn main() {
let x = 5;
match x {
1 => println!("một"),
2 | 3 => println!("hai hoặc ba"), // | nối nhiều pattern
4..=6 => println!("từ bốn đến sáu"), // khoảng đóng cả hai đầu
_ => println!("giá trị khác"),
}
}
..= cũng dùng được với char:
fn main() {
let c = 'f';
match c {
'a'..='j' => println!("chữ cái đầu bảng chữ cái"),
'k'..='z' => println!("chữ cái cuối bảng chữ cái"),
_ => println!("không phải chữ thường"),
}
}
Destructure struct và enum
struct Diem {
x: i32,
y: i32,
}
fn main() {
let p = Diem { x: 0, y: 7 };
let Diem { x, y } = p;
println!("{x} {y}");
match p {
Diem { x: 0, y } => println!("Trên trục tung, y = {y}"),
Diem { x, y: 0 } => println!("Trên trục hoành, x = {x}"),
Diem { x, y } => println!("Không nằm trên trục nào: ({x}, {y})"),
}
}
Destructure lồng nhau qua enum chứa struct:
enum Mau {
Rgb(i32, i32, i32),
}
enum ThongDiep {
DoiMau(Mau),
ImLang,
}
fn main() {
let td = ThongDiep::DoiMau(Mau::Rgb(0, 160, 255));
match td {
ThongDiep::DoiMau(Mau::Rgb(r, g, b)) => {
println!("Đổi màu RGB: {r}, {g}, {b}");
}
ThongDiep::ImLang => println!("Im lặng"),
}
}
Bỏ qua giá trị
_ bỏ qua toàn bộ một giá trị mà không bind vào biến nào — khác một biến bắt đầu bằng _ (như
_x), vẫn bind giá trị vào biến đó (chỉ tắt cảnh báo “biến không dùng”), nên vẫn giữ quyền sở hữu
giá trị cho tới hết scope. _ trần không bind gì cả, giá trị bị drop ngay tại chỗ nếu nó
không implement Copy:
fn main() {
let s = Some(String::from("dữ liệu"));
if let Some(_) = s {
println!("có giá trị");
}
// s đã bị move vào nhánh match rồi drop ngay khi nhánh kết thúc,
// vì `_` không giữ lại quyền sở hữu ở biến nào cả — nhưng vẫn không dùng lại được s ở đây
// do giá trị bên trong Some đã bị lấy ra để khớp.
}
.. bỏ qua phần còn lại của một struct hoặc tuple, không cần liệt kê hết field không quan tâm:
struct Diem3D {
x: i32,
y: i32,
z: i32,
}
fn main() {
let p = Diem3D { x: 1, y: 2, z: 3 };
let Diem3D { x, .. } = p;
println!("chỉ cần x = {x}");
let so = (1, 2, 3, 4, 5);
match so {
(dau, .., cuoi) => println!("đầu = {dau}, cuối = {cuoi}"),
}
}
Match guard
Thêm điều kiện if sau một pattern — chỉ khớp nhánh đó nếu cả pattern khớp lẫn điều kiện
đúng. Match guard giải quyết được tình huống pattern tự nó không diễn tả nổi, đặc biệt khi cần so
sánh với một biến từ scope ngoài match (pattern thường tạo biến mới, che khuất biến ngoài cùng
tên — match guard vẫn thấy được biến ngoài vì nó là một expression bình thường, không phải cú pháp
pattern):
fn main() {
let x = Some(5);
let y = 10;
match x {
Some(gia_tri) if gia_tri == y => println!("bằng y"),
Some(gia_tri) => println!("Some với giá trị {gia_tri}, y ở ngoài là {y}"),
None => println!("không có gì"),
}
}
@ binding
@ vừa bind giá trị vào một biến, vừa kiểm tra giá trị đó khớp một pattern khác — cần khi vừa muốn
dùng giá trị vừa muốn ràng buộc phạm vi nó trong cùng một nhánh:
fn main() {
let id = 5;
match id {
id_hop_le @ 3..=7 => {
println!("ID {id_hop_le} nằm trong khoảng hợp lệ")
}
10..=12 => println!("ID nằm trong khoảng đặc biệt, không lấy ra biến"),
_ => println!("ID khác"),
}
}
Không có @, nhánh 3..=7 => ... vẫn kiểm tra được khoảng, nhưng không có cách nào lấy ra giá trị
cụ thể đã khớp (chỉ có thể dùng lại biến gốc id từ ngoài, không phải lúc nào cũng tiện hoặc rõ
ràng bằng cách đặt tên riêng ngay tại chỗ khớp).
Tính năng nâng cao
Bốn chủ đề còn lại, mỗi cái mở ra một góc khác của Rust ít gặp trong code hàng ngày nhưng cần biết
để đọc hiểu code hệ thống, thư viện nền tảng, hay code sinh tự động: unsafe (tắt một số kiểm tra
của trình biên dịch có kiểm soát), trait/type nâng cao, và macro (sinh code lúc biên dịch).
Unsafe Rust
Mọi đảm bảo an toàn bộ nhớ nói từ đầu sách — borrow checker, không tham chiếu treo, không data race
— đều do trình biên dịch tĩnh kiểm tra được, nghĩa là nó phải từ chối cả một số đoạn code thật
ra vẫn an toàn nhưng trình biên dịch không đủ thông tin để chứng minh điều đó. unsafe là lối
thoát có kiểm soát cho đúng những trường hợp đó — mở khoá thêm 5 khả năng bị cấm ở Rust thường:
- Giải tham chiếu một con trỏ thô (raw pointer).
- Gọi một hàm hoặc method
unsafe. - Truy cập/sửa một biến
static mut. - Implement một trait
unsafe. - Truy cập field của một
union.
Quan trọng: unsafe không tắt borrow checker hay bất kỳ kiểm tra nào khác — nó chỉ mở thêm đúng
5 khả năng trên, còn mọi quy tắc khác vẫn áp dụng nguyên vẹn.
Con trỏ thô
fn main() {
let mut num = 5;
let r1 = &raw const num;
let r2 = &raw mut num;
unsafe {
println!("r1: {}", *r1);
*r2 += 1;
println!("r2: {}", *r2);
}
}
*const T/*mut T khác tham chiếu thường ở chỗ: có thể null, không đảm bảo trỏ tới bộ nhớ hợp lệ,
và được phép có nhiều con trỏ thô khả biến cùng trỏ tới một chỗ cùng lúc — borrow checker không
theo dõi con trỏ thô. Tạo con trỏ thô (&raw const/&raw mut) là an toàn; chỉ giải tham chiếu
(*r1) mới cần khối unsafe, vì đó là lúc thật sự có rủi ro đọc/ghi vùng nhớ không hợp lệ.
Dùng unsafe để viết một API an toàn
unsafe bên trong không có nghĩa là API công khai phải unsafe theo — cách dùng phổ biến nhất: bọc
một thao tác mà bản thân nó cần unsafe (vì borrow checker không đủ thông minh để tự chứng minh)
bên trong một hàm thường, nếu người viết tự đảm bảo được lời gọi đó luôn an toàn:
fn chia_doi_kha_bien(day: &mut [i32], vi_tri: usize) -> (&mut [i32], &mut [i32]) {
let do_dai = day.len();
let con_tro = day.as_mut_ptr();
assert!(vi_tri <= do_dai);
unsafe {
(
std::slice::from_raw_parts_mut(con_tro, vi_tri),
std::slice::from_raw_parts_mut(con_tro.add(vi_tri), do_dai - vi_tri),
)
}
}
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let (a, b) = chia_doi_kha_bien(&mut v, 3);
println!("{a:?} {b:?}");
}
Trả về hai tham chiếu khả biến từ cùng một slice là điều borrow checker không tự chứng minh
được an toàn (nó không biết hai nửa đó không chồng lấn nhau) — nhưng người viết hàm này biết chắc,
vì vi_tri <= do_dai đã kiểm tra và hai slice ghép từ điểm cắt đó chưa bao giờ chồng lấn. unsafe
cho phép diễn đạt sự thật đó, assert! bên ngoài đảm bảo lời hứa đó luôn đúng trước khi vào phần
unsafe. Người gọi chia_doi_kha_bien không cần biết gì về unsafe cả — API nhìn từ ngoài hoàn
toàn an toàn.
static mut
Một biến static khai báo mut là trạng thái toàn cục, khả biến — truy cập hay sửa nó đều cần
unsafe, kể cả chỉ đọc, vì nhiều luồng cùng truy cập một static mut không đồng bộ chính là một
data race tiềm ẩn mà trình biên dịch không kiểm soát được:
static mut BO_DEM: i32 = 0;
fn tang_bo_dem() {
unsafe {
*(&raw mut BO_DEM) += 1;
}
}
fn main() {
tang_bo_dem();
tang_bo_dem();
unsafe {
println!("{}", *(&raw const BO_DEM));
}
}
Trong code thực tế, gần như luôn nên dùng std::sync::atomic hoặc Mutex<T> (chương Concurrency)
thay vì static mut — cả hai cho trạng thái toàn cục khả biến an toàn giữa nhiều luồng mà không
cần unsafe ở nơi dùng. static mut chủ yếu còn xuất hiện trong code cấp rất thấp hoặc tương thích
ngược.
Trait nâng cao
Associated type
Iterator (chương Closure và Iterator) khai báo type Item thay vì một tham số generic
Iterator<T>. Khác biệt cốt lõi: với associated type, một kiểu chỉ implement Iterator được
đúng một lần, Item cụ thể do chính kiểu đó quyết định cố định; với generic trait (Trait<T>),
một kiểu implement được nhiều lần cho nhiều T khác nhau. Iterator chọn associated type vì
hợp lý về mặt ngữ nghĩa: một kiểu chỉ nên cho ra đúng một loại phần tử khi duyệt, không có lý do gì
để cùng một kiểu vừa là Iterator<i32> vừa là Iterator<String>.
Tham số kiểu generic mặc định
Add (trait cho toán tử +) khai báo trait Add<Rhs = Self> — tham số Rhs (vế phải) mặc định
bằng chính Self nếu không chỉ định gì khác, cho phép vừa cộng hai giá trị cùng kiểu theo cách tự
nhiên, vừa ghi đè để cộng được với một kiểu khác hẳn khi cần:
use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Diem {
x: i32,
y: i32,
}
impl Add for Diem {
// dùng Rhs mặc định = Self
type Output = Diem;
fn add(self, khac: Diem) -> Diem {
Diem {
x: self.x + khac.x,
y: self.y + khac.y,
}
}
}
struct Milimet(u32);
struct Met(u32);
impl Add<Met> for Milimet {
// ghi đè Rhs thành Met
type Output = Milimet;
fn add(self, khac: Met) -> Milimet {
Milimet(self.0 + khac.0 * 1000)
}
}
fn main() {
let p1 = Diem { x: 1, y: 2 };
let p2 = Diem { x: 3, y: 4 };
println!("{:?}", p1 + p2);
let tong = Milimet(10) + Met(1);
println!("{} mm", tong.0);
}
Cú pháp đủ điều kiện để gỡ nhập nhằng
Hai trait khác nhau (hoặc một trait và một method riêng của kiểu) có thể cùng đặt tên method trùng
nhau. Gọi .method() thường sẽ ưu tiên method riêng của kiểu nếu có; muốn gọi đích danh method của
một trait cụ thể, cần cú pháp đủ điều kiện:
trait KeuGi {
fn keu(&self) -> String {
String::from("...")
}
}
trait KeuGiKhac {
fn keu(&self) -> String {
String::from("???")
}
}
struct Vit;
impl KeuGi for Vit {
fn keu(&self) -> String {
String::from("Cạc cạc")
}
}
impl KeuGiKhac for Vit {
fn keu(&self) -> String {
String::from("Quạc")
}
}
fn main() {
let v = Vit;
println!("{}", KeuGi::keu(&v));
println!("{}", <Vit as KeuGiKhac>::keu(&v));
}
KeuGi::keu(&v) là dạng gọn (đủ dùng khi trình biên dịch tự suy ra được Self từ tham số); dạng
đầy đủ <Vit as KeuGiKhac>::keu(&v) cần thiết khi cả tên trait lẫn kiểu đều phải nói rõ mới hết
nhập nhằng.
Supertrait
Một trait có thể yêu cầu kiểu implement nó phải đồng thời implement một trait khác — gọi là supertrait — cho phép dùng method của trait kia ngay trong phần mặc định của trait này:
use std::fmt;
trait HienThiDep: fmt::Display {
fn in_dep(&self) {
println!("*** {self} ***");
}
}
struct Diem {
x: i32,
y: i32,
}
impl fmt::Display for Diem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
impl HienThiDep for Diem {}
fn main() {
let p = Diem { x: 1, y: 2 };
p.in_dep();
}
{self} bên trong in_dep chỉ hợp lệ vì HienThiDep: fmt::Display đảm bảo mọi kiểu implement
HienThiDep cũng implement Display — thiếu ràng buộc supertrait đó, trình biên dịch không có gì
đảm bảo self in ra được.
Newtype pattern để lách quy tắc mồ côi
Quy tắc mồ côi (chương Trait) cấm implement một trait ngoài crate cho một kiểu cũng ngoài crate. Bọc kiểu ngoài trong một tuple struct của riêng mình (newtype) tạo ra một kiểu nội bộ crate, hợp lệ để implement bất kỳ trait ngoài nào lên nó:
use std::fmt;
struct Bao(Vec<String>);
impl fmt::Display for Bao {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let b = Bao(vec![String::from("hello"), String::from("world")]);
println!("{b}");
}
Vec<String> (kiểu ngoài) và Display (trait ngoài) không thể implement trực tiếp cho nhau trong
crate của mình — Bao là một kiểu mới, dù chỉ bọc đúng một Vec<String> bên trong, đã đủ điều kiện
để implement Display hợp lệ theo quy tắc mồ côi. Đánh đổi: Bao không tự động có sẵn các method
của Vec<String> — phải truy cập qua self.0, hoặc implement Deref (chương Con trỏ thông minh)
nếu muốn dùng xuyên qua như thể chính là Vec<String>.
Type nâng cao
Type alias
type tạo một bí danh — tên khác cho một kiểu đã có, không phải một kiểu mới:
type Kilomet = i32;
fn main() {
let x: i32 = 5;
let y: Kilomet = 5;
println!("{}", x + y); // hợp lệ: Kilomet chỉ là i32 với tên khác
}
Khác newtype pattern (struct Kilomet(i32) ở phần trước) — tuple struct tạo một kiểu thật sự
mới, không thể cộng thẳng với i32 nếu thiếu trait phù hợp; type Kilomet = i32 chỉ là một tên
gọi khác của đúng một kiểu, dùng lẫn lộn được với i32 ở mọi nơi. Type alias hữu ích để rút gọn một
kiểu dài, lặp lại nhiều lần trong code — ví dụ kiểu closure boxed hay gặp khi lưu callback:
type ThunkNangTru = Box<dyn Fn() + Send + 'static>;
fn dang_ky(f: ThunkNangTru) {
f();
}
fn main() {
dang_ky(Box::new(|| println!("đã chạy")));
}
Never type
! là kiểu của một expression không bao giờ trả về giá trị nào cả — panic!, một loop không
break, continue, hay std::process::exit đều có kiểu !. Điều đặc biệt: ! ép kiểu được thành
bất kỳ kiểu nào khác, nên một nhánh match trả ! (ví dụ gọi panic! hoặc return sớm) vẫn
phối hợp được với các nhánh khác trả một kiểu cụ thể, không gây lỗi “các nhánh khác kiểu”:
fn doan(so: i32) -> i32 {
let mo_ta = match so {
1 => "một",
_ => return -1, // nhánh này có kiểu `!`, ép được thành i32 để khớp nhánh kia
};
println!("{mo_ta}");
so
}
fn main() {
println!("{}", doan(1));
println!("{}", doan(9));
}
Kiểu có kích thước động (DST) và Sized
str (không phải &str) và dyn Trait (không phải &dyn Trait/Box<dyn Trait>) là kiểu có
kích thước không biết trước lúc biên dịch (dynamically sized type) — không thể có một biến kiểu
str trần, luôn phải đứng sau một con trỏ (&str, Box<str>) mang thêm thông tin về độ dài/vtable
đi kèm. Mọi tham số generic ngầm định mang ràng buộc T: Sized (kích thước cố định biết trước) trừ
khi nới lỏng tường minh bằng ?Sized:
fn in_ra<T: ?Sized + std::fmt::Debug>(gia_tri: &T) {
println!("{gia_tri:?}");
}
fn main() {
in_ra("một chuỗi str động");
in_ra(&42);
}
?Sized cho phép T là cả kiểu Sized bình thường lẫn kiểu không xác định kích thước như str —
đổi lại, tham số phải luôn nhận qua một dạng con trỏ (&T), không bao giờ nhận T theo giá trị,
đúng như bản chất DST không thể tồn tại trực tiếp trên stack như một giá trị trần.
Macro
Hàm nhận giá trị lúc chạy, trả về giá trị. Macro nhận code (dưới dạng token) lúc biên
dịch, sinh ra code khác thay thế vào chỗ gọi nó — khác biệt này cho macro làm được những việc
hàm không làm được: nhận số lượng tham số khác nhau tuỳ lần gọi (println! nhận bao nhiêu tham số
cũng được, một hàm thường thì không), hay tự động sinh cả một implementation trait cho một kiểu
(#[derive(Debug)] — bản thân cũng là một loại macro).
Macro khai báo (macro_rules!)
So khớp cấu trúc cú pháp của code truyền vào với một pattern, rồi thay bằng code mẫu tương ứng —
tương tự match nhưng so khớp trên code, không phải giá trị lúc chạy. Viết một phiên bản đơn
giản hoá của vec!:
macro_rules! vec_cua_toi {
( $( $x:expr ),* ) => {
{
let mut v = Vec::new();
$(
v.push($x);
)*
v
}
};
}
fn main() {
let v: Vec<i32> = vec_cua_toi![1, 2, 3];
println!("{v:?}");
}
$x:expr khớp một expression bất kỳ, đặt tên $x. $(...),* nghĩa là “lặp lại phần này, các lượt
cách nhau dấu phẩy, không giới hạn số lần” — khớp được vec_cua_toi![1, 2, 3] lẫn vec_cua_toi![]
(không phần tử) hay vec_cua_toi![1] (một phần tử), sinh ra đúng số lệnh v.push tương ứng số
expression đã truyền vào.
Macro thủ tục (procedural macro)
Mạnh hơn macro_rules! nhưng phức tạp hơn nhiều: nhận trực tiếp luồng token (TokenStream) làm đầu
vào, chạy code Rust bất kỳ để xử lý (thường dùng crate syn để phân tích cú pháp thành cây cú pháp,
quote để sinh code trở lại), trả về một TokenStream khác thay thế vào chỗ gọi. Luôn định nghĩa
trong một crate riêng, khai báo proc-macro = true — không viết chung được với code thường trong
cùng crate, nên nằm ngoài phạm vi một ví dụ ngắn có thể chạy thử trực tiếp ở đây. Ba dạng:
- Custom derive:
#[derive(TenTrait)]— tự sinh implementation của một trait, dựa trên cấu trúc field của struct/enum đang derive (giống hệt cách#[derive(Debug)]tự sinh code in ra, chỉ khác đây là do chính người viết crate định nghĩa cho trait của riêng mình). - Attribute-like:
#[ten_attribute]— attribute tuỳ chỉnh gắn lên bất kỳ item nào, không nhất thiết gắn vớiderive. - Function-like:
ten_macro!(...)— trông giốngmacro_rules!khi gọi, nhưng cú pháp bên trong dấu ngoặc không cần là Rust hợp lệ, macro thủ tục tự định nghĩa cách phân tích cú pháp lấy vào.
Cả ba đều là công cụ để tự động sinh code lặp đi lặp lại dựa trên cấu trúc code có sẵn — thứ mà
nếu viết tay sẽ phải chép lại gần như y hệt cho mỗi kiểu dữ liệu mới, đánh đổi lấy sự phức tạp lúc
biên dịch (cần một crate proc-macro riêng, công cụ phân tích cú pháp) để tránh sự lặp lại đó lúc
viết code.
Từ khoá
Danh sách từ khoá của Rust — không dùng được làm tên biến, hàm, kiểu, module, v.v. trừ trường hợp raw identifier nói ở cuối phụ lục này.
Đang dùng
| Từ khoá | Ý nghĩa |
|---|---|
as | ép kiểu tường minh; đổi tên item khi use |
async | đánh dấu một hàm/khối trả về Future thay vì chạy ngay |
await | tạm dừng chờ một Future tới khi Ready |
break | thoát khỏi vòng lặp ngay lập tức |
const | khai báo hằng số, hoặc một tham số/hàm hằng |
continue | bỏ qua phần còn lại của lượt lặp hiện tại, sang lượt kế |
crate | tham chiếu tới module gốc của crate hiện tại |
dyn | đánh dấu dynamic dispatch qua trait object (dyn Trait) |
else | nhánh thay thế cho if, if let, let-else |
enum | định nghĩa một enum |
extern | khai báo liên kết với hàm/biến từ ngoài crate (FFI) |
false | giá trị bool sai |
fn | định nghĩa một hàm |
for | lặp qua các phần tử của một iterator; cũng dùng trong cú pháp trait cho lifetime |
if | rẽ nhánh dựa trên điều kiện bool |
impl | implement chức năng cho một kiểu, hoặc một trait cho kiểu đó |
in | một phần cú pháp của for |
let | gán một giá trị cho biến |
loop | lặp vô hạn cho tới khi break |
match | so khớp một giá trị với các pattern |
mod | định nghĩa một module |
move | ép closure lấy quyền sở hữu mọi giá trị nó capture |
mut | đánh dấu khả biến cho tham chiếu, con trỏ, biến |
pub | đánh dấu công khai cho struct, method, module |
ref | bind theo tham chiếu thay vì theo giá trị trong một pattern |
return | trả về giá trị, thoát khỏi hàm ngay |
Self | bí danh cho kiểu đang định nghĩa/implement |
self | chính đối tượng hoặc module hiện tại |
static | biến toàn cục, hoặc lifetime kéo dài suốt chương trình |
struct | định nghĩa một struct |
super | module cha của module hiện tại |
trait | định nghĩa một trait |
true | giá trị bool đúng |
type | định nghĩa type alias, hoặc associated type |
union | định nghĩa một union (chỉ có ý nghĩa trong ngữ cảnh unsafe) |
unsafe | đánh dấu code/hàm/trait bỏ qua một số kiểm tra an toàn của Rust |
use | đưa một đường dẫn (module, hàm, kiểu) vào scope |
where | tách ràng buộc kiểu ra khỏi chữ ký, dễ đọc hơn khi ràng buộc dài |
while | lặp khi một điều kiện còn đúng |
Dự trữ cho tương lai
Không dùng được dù hiện tại chưa có chức năng: abstract, become, box, do, final, macro,
override, priv, try, typeof, unsized, virtual, yield, gen.
Raw identifier
Đặt tiền tố r# trước một từ vốn là từ khoá để vẫn dùng được nó làm tên định danh — chủ yếu cần khi
gọi code từ một edition Rust khác đã đưa thêm từ khoá mới (ví dụ tên biến match viết bằng
r#match), không phải cú pháp dùng thường xuyên trong code mới.
Toán tử và ký hiệu
Toán tử
Phần lớn toán tử có thể nạp chồng (overload) thông qua trait tương ứng trong std::ops — implement
trait đó cho kiểu của mình để toán tử hoạt động được trên kiểu đó.
| Toán tử | Ý nghĩa | Trait tương ứng (nếu overload được) |
|---|---|---|
+ | cộng | Add |
- | trừ; phủ định một ngôi | Sub, Neg |
* | nhân; giải tham chiếu | Mul, Deref |
/ | chia | Div |
% | chia lấy dư | Rem |
! | phủ định logic; kiểu never | Not |
& | tham chiếu bất biến; AND theo bit | BitAnd |
&& | AND logic (ngắn mạch) | |
&mut | tham chiếu khả biến | |
| | OR theo bit | BitOr |
|| | OR logic (ngắn mạch) | |
^ | XOR theo bit | BitXor |
<< | dịch trái theo bit | Shl |
>> | dịch phải theo bit | Shr |
== | so sánh bằng | PartialEq |
!= | so sánh khác | PartialEq |
< > <= >= | so sánh thứ tự | PartialOrd |
= | gán giá trị | |
+= -= *= /= %= | gán kèm phép toán | AddAssign, SubAssign, … |
? | lan truyền lỗi/None sớm khỏi hàm | |
.. ..= | tạo một range (nửa mở/đóng) | |
@ | bind giá trị trong pattern |
Ký hiệu khác
| Ký hiệu | Ý nghĩa |
|---|---|
:: | đường dẫn tới item trong một module/kiểu (std::collections::HashMap) |
-> | kiểu trả về của hàm/closure |
=> | phân tách pattern và code trong một nhánh match |
'a | tham số lifetime (bắt đầu bằng dấu ') |
#[...] | attribute gắn lên item ngay bên dưới (#[derive(Debug)], #[test]) |
// /* */ | comment một dòng / nhiều dòng |
/// //! | doc comment cho item bên dưới / cho item chứa nó |
_ | pattern bỏ qua giá trị; ký tự phân cách trong số (1_000_000) |
! (sau tên) | gọi một macro (println!, vec!) |
dyn Trait | trait object, dynamic dispatch |
impl Trait | một kiểu cụ thể nào đó implement Trait, không tiết lộ kiểu chính xác |
Trait có thể derive
Attribute #[derive(...)] tự sinh implementation cho một số trait chuẩn, dựa trên cấu trúc field
của struct/enum — chỉ áp dụng được khi mọi field cũng implement đúng trait đó (derive Clone
cho một struct chứa field không phải Clone sẽ báo lỗi biên dịch).
| Trait | Tự sinh gì |
|---|---|
Debug | định dạng in ra phục vụ debug qua {:?}/{:#?} — gần như luôn nên derive cho mọi kiểu tự định nghĩa |
PartialEq | so sánh bằng nhau (==, !=) — so từng field, tất cả field phải bằng nhau thì hai instance mới bằng nhau |
Eq | khẳng định PartialEq của kiểu này luôn phản xạ (x == x luôn đúng) — không cho f32/f64 vì NaN != NaN |
PartialOrd | so sánh thứ tự (<, >, …) — so field theo đúng thứ tự khai báo, field nào khác trước quyết định kết quả |
Ord | như PartialOrd nhưng khẳng định luôn so sánh được với mọi giá trị khác (không như f32/f64 với NaN) |
Clone | sinh method .clone() — deep clone từng field |
Copy | cho phép gán bằng cách copy bit thay vì move — chỉ hợp lệ nếu mọi field đều Copy (không có String, Vec, …) |
Hash | sinh cách băm giá trị — cần để dùng làm khoá trong HashMap/HashSet |
Default | sinh hàm ::default() trả về một instance với giá trị mặc định cho từng field |
Thứ tự thường đi kèm nhau: một kiểu dùng làm khoá HashMap cần cả Eq lẫn Hash; một kiểu cần sắp
xếp (Vec::sort) cần Ord.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
struct NguoiDung {
id: u32,
ten: String,
}
fn main() {
let a = NguoiDung {
id: 1,
ten: String::from("An"),
};
let b = a.clone();
println!("{a:?}");
assert_eq!(a, b);
let mac_dinh = NguoiDung::default();
println!("{mac_dinh:?}");
}
Công cụ phát triển hữu ích
Ba công cụ sau đều cài được qua rustup component add, không cần thêm gì ngoài rustup đã cài ở
chương Bắt đầu:
rustup component add rustfmt clippy rust-analyzer
rustfmt
Format code Rust theo một chuẩn thống nhất — loại bỏ hẳn tranh cãi về style cá nhân (đặt ngoặc chỗ nào, thụt lề bao nhiêu):
cargo fmt # format toàn bộ crate
cargo fmt --check # chỉ kiểm tra, không sửa — dùng trong CI
Clippy
Bộ lint mở rộng, bắt được nhiều lỗi và cách viết chưa tối ưu mà rustc thường không cảnh báo — ví
dụ dùng .clone() thừa, so sánh số thực dấu phẩy động bằng ==, dùng vòng lặp thủ công thay vì
iterator adaptor sẵn có:
cargo clippy
Đáng chạy thường xuyên bên cạnh cargo build — nhiều gợi ý của Clippy phản ánh đúng thành ngữ
(idiom) mà cộng đồng Rust coi là cách viết chuẩn, không chỉ là sở thích cá nhân.
rust-analyzer
Language server cho Rust — cắm vào editor (VS Code, Neovim, …) để có gợi ý kiểu ngay khi gõ, nhảy
tới định nghĩa, tự động sửa lỗi nhỏ, xem tài liệu inline. Hầu hết editor hiện đại hỗ trợ qua
extension riêng (ví dụ extension “rust-analyzer” chính thức trên VS Code) — extension đó tự tìm và
dùng binary đã cài qua rustup component add ở trên.
cargo doc
Sinh tài liệu HTML từ doc comment (///, //!, chương Cargo nâng cao) cho crate hiện tại và toàn
bộ dependency:
cargo doc --open
cargo-binstall
Đã giới thiệu ở chương Bắt đầu — cài nhanh các công cụ dòng lệnh viết bằng Rust (kể cả các công cụ khác không nằm trong danh sách này) bằng binary dựng sẵn thay vì compile từ mã nguồn.