Bỏ qua để đến nội dung

Câu hỏi phỏng vấn Rust

Đây là những câu hỏi xuất hiện đi xuất hiện lại trong các buổi phỏng vấn Rust, từ vị trí junior đến senior. Mỗi câu trả lời đủ ngắn để bạn nói thành lời, và có liên kết tới bài học giải thích chủ đề đó kỹ hơn.

Đừng học thuộc lòng từng chữ. Người phỏng vấn nhận ra ngay, và họ thường hỏi thêm một câu nữa để xem bạn có thật sự hiểu không. Hãy dùng trang này để kiểm tra những gì bạn đã biết, rồi mở bài học được liên kết cho những chỗ còn lung lay. Nếu bạn giải thích được câu trả lời bằng lời của chính mình và viết được một ví dụ nhỏ, bạn đã sẵn sàng.

Cargo là công cụ build và trình quản lý package chính thức của Rust. Nó đi kèm khi bạn cài Rust. Chỉ với một công cụ, bạn có thể:

  • tạo dự án (cargo new),
  • build và chạy dự án (cargo build, cargo run),
  • chạy test (cargo test),
  • thêm thư viện, gọi là crate, từ crates.io (cargo add serde).

Thiết lập và các thư viện phụ thuộc của dự án nằm trong Cargo.toml. Phiên bản chính xác được khóa lại trong Cargo.lock, nên mỗi lần build đều cho kết quả giống nhau. Một điểm cộng nên nhắc tới: ở các ngôn ngữ khác thường cần vài công cụ cho việc này, còn trong Rust ai cũng dùng Cargo, nên dự án nào trông cũng quen thuộc.

Tìm hiểu thêm: Cargo và crate, Bắt đầu.

mut cho phép bạn thay đổi giá trị của cùng một biến. Kiểu phải giữ nguyên.

Shadowing (che khuất) nghĩa là viết let lần nữa với cùng tên. Nó tạo ra một biến mới che đi biến cũ. Biến mới thậm chí có thể có kiểu khác.

fn main() {
let spaces = " ";
let spaces = spaces.len(); // shadowing: một biến mới, giờ là số
println!("{spaces}");
let mut count = 1;
count += 1; // mut: cùng một biến nhận giá trị mới
println!("{count}");
}

Kết quả:

3
2
▶ Tự chạy thử

Shadowing rất tiện khi biến đổi một giá trị từng bước (như phân tích văn bản thành số) mà không phải nghĩ ra những cái tên như input_str và input_num. Và biến cuối cùng vẫn là bất biến.

Tìm hiểu thêm: Biến.

Hàm được gọi lúc chạy, với một số lượng đối số cố định và có kiểu. Macro chạy lúc biên dịch: nó nhận code và viết thêm code trước khi trình biên dịch kiểm tra. Bạn nhận ra macro nhờ dấu !, như println!, vec! và format!.

Macro làm được những việc hàm không làm được, như nhận số lượng đối số thay đổi (println!("{} {}", a, b)) hay kiểm tra chuỗi định dạng lúc biên dịch. Macro của Rust còn làm việc trên cấu trúc của code, không phải văn bản thô, nên chúng tránh được các bug kinh điển của macro trong C:

macro_rules! square {
($x:expr) => {
$x * $x
};
}
fn main() {
// Trong C, một macro dựa trên văn bản sẽ cho 2 + 1 * 2 + 1 = 5.
println!("{}", square!(2 + 1));
}

Kết quả:

9
▶ Tự chạy thử

Sự đánh đổi thật lòng: macro khó đọc, khó viết và khó debug hơn. Hãy dùng hàm khi hàm làm được việc.

Tìm hiểu thêm: Macro.

Hỏi: “Zero-cost abstractions” nghĩa là gì?

Phần tiêu đề “Hỏi: “Zero-cost abstractions” nghĩa là gì?”

Nó nghĩa là các tính năng cấp cao không được làm chương trình chậm hơn so với code cấp thấp mà bạn tự viết tay. “Thứ bạn không dùng thì bạn không phải trả giá, còn thứ bạn dùng thì bạn cũng không tự viết tốt hơn được.”

Ví dụ, một chuỗi iterator như .iter().filter(...).map(...).sum() thường được biên dịch ra mã máy giống hệt một vòng lặp viết tay. Generic là một ví dụ khác: trình biên dịch tạo ra một bản sao chuyên biệt cho mỗi kiểu bạn dùng (gọi là monomorphization), nên không có bước tra cứu nào lúc chạy.

Hãy thành thật về phần “chi phí”: đó là chi phí lúc chạy bằng 0. Bạn vẫn trả giá bằng thời gian biên dịch, và generic có thể làm file chương trình lớn hơn.

Tìm hiểu thêm: Iterator, Generic.

Hỏi: Iterator trong Rust có “lười” (lazy) không?

Phần tiêu đề “Hỏi: Iterator trong Rust có “lười” (lazy) không?”

Có. Các phương thức như .map() và .filter() tự chúng không làm gì cả. Chúng chỉ tạo ra một bản mô tả công việc. Chẳng có gì xảy ra cho đến khi có thứ gì đó tiêu thụ (consume) iterator, như .collect(), .sum() hoặc một vòng lặp for.

fn main() {
let numbers = vec![1, 2, 3];
let doubled = numbers.iter().map(|n| {
println!("nhân đôi {n}");
n * 2
});
println!("Chưa có gì chạy cả.");
let result: Vec<i32> = doubled.collect();
println!("{:?}", result);
}

Kết quả:

Chưa có gì chạy cả.
nhân đôi 1
nhân đôi 2
nhân đôi 3
[2, 4, 6]
▶ Tự chạy thử

Đó cũng là lý do trình biên dịch cảnh báo nếu bạn viết numbers.iter().map(...); mà không bao giờ dùng kết quả: nó sẽ chẳng làm gì cả.

Tìm hiểu thêm: Iterator.

Có ba quy tắc:

  1. Mỗi giá trị trong Rust có một chủ sở hữu (một biến).
  2. Tại một thời điểm chỉ có một chủ sở hữu.
  3. Khi chủ sở hữu ra khỏi phạm vi (scope), giá trị bị drop (bộ nhớ của nó được giải phóng).

Các quy tắc này cho phép Rust giải phóng bộ nhớ đúng lúc mà không cần bộ thu gom rác (garbage collector), và cũng không cần bạn tự gọi free. Trình biên dịch kiểm tra chúng, nên những lỗi như “use after free” (dùng sau khi giải phóng) hay “double free” (giải phóng hai lần) không thể xảy ra trong code an toàn.

Tìm hiểu thêm: Quyền sở hữu, Phạm vi.

Hỏi: Move, copy và clone khác nhau thế nào?

Phần tiêu đề “Hỏi: Move, copy và clone khác nhau thế nào?”
  • Move: gán hoặc truyền một giá trị sẽ chuyển quyền sở hữu. Biến cũ không dùng được nữa. Đây là mặc định với các kiểu như String và Vec. Không có gì bị sao chép ngoài một con trỏ, độ dài và dung lượng nhỏ gọn.
  • Copy: với các kiểu nhỏ, đơn giản như i32, f64, bool và char, phép gán tạo một bản sao từng bit rẻ tiền, và cả hai biến vẫn dùng được.
  • Clone: .clone() là một bản sao sâu tường minh. Với String, nó sao chép toàn bộ văn bản sang vùng nhớ mới. Việc này có thể tốn kém, nên Rust bắt bạn phải viết ra.
fn main() {
let x = 5;
let y = x; // Copy: x vẫn dùng được
println!("{x} {y}");
let a = String::from("hi");
let b = a.clone(); // Clone: một bản sao đầy đủ của văn bản
let c = a; // Move: `a` không dùng được sau dòng này
println!("{b} {c}");
}

Kết quả:

5 5
hi hi
▶ Tự chạy thử

Tìm hiểu thêm: Quyền sở hữu.

Hỏi: Trait Copy là gì, và những kiểu nào có nó?

Phần tiêu đề “Hỏi: Trait Copy là gì, và những kiểu nào có nó?”

Copy là một trait đánh dấu (marker trait), nói rằng “kiểu này có thể được nhân bản chỉ bằng cách sao chép các bit của nó”. Các kiểu là Copy sẽ được sao chép thay vì bị move khi gán.

Số nguyên, số thực, bool, char, tham chiếu dùng chung (&T), và tuple hay mảng chỉ gồm các kiểu Copy đều là Copy. String, Vec và Box thì không, vì chúng sở hữu bộ nhớ ở nơi khác, và sao chép con trỏ sẽ tạo ra hai chủ sở hữu.

Bạn có thể derive nó cho kiểu của chính mình, nhưng chỉ khi mọi trường đều là Copy:

#[derive(Clone, Copy)]
struct User {
name: String,
age: u32,
}
error[E0204]: the trait `Copy` cannot be implemented for this type
--> src/main.rs:2:8
|
1 | #[derive(Clone, Copy)]
| ---- in this derive macro expansion
2 | struct User {
| ^^^^
3 | name: String,
| ------------ this field does not implement `Copy`

Trình biên dịch (bằng tiếng Anh) nói rằng không thể triển khai Copy cho kiểu này, vì trường name không phải Copy. Copy luôn đòi hỏi cả Clone. Ngoài ra, một kiểu triển khai Drop không thể là Copy.

Tìm hiểu thêm: Các trait thông dụng, Quyền sở hữu.

  • String là một đoạn văn bản có chủ sở hữu, có thể mở rộng, nằm trên heap. Bạn có thể thay đổi nó, ví dụ bằng push_str.
  • &str (một “string slice”) là một góc nhìn mượn vào một đoạn văn bản nào đó. Nó chỉ là một con trỏ và một độ dài. Chuỗi literal như "hello" là &str.

Một quy tắc hay: hàm nào chỉ đọc văn bản thì nên nhận &str. Như vậy nó nhận được cả hai, vì một &String tự động chuyển thành &str.

fn greet(name: &str) {
println!("Xin chào, {name}!");
}
fn main() {
let owned: String = String::from("Ana");
let literal: &str = "Ben";
greet(&owned); // &String dùng được ở chỗ cần &str
greet(literal);
let mut full = owned;
full.push_str(" Smith"); // chỉ String mới mở rộng được
greet(&full);
}

Kết quả:

Xin chào, Ana!
Xin chào, Ben!
Xin chào, Ana Smith!
▶ Tự chạy thử

Tìm hiểu thêm: Chuỗi, Slice.

Tại mọi thời điểm, với một giá trị nhất định, bạn có thể có hoặc:

  • bao nhiêu tham chiếu dùng chung (&T) cũng được, hoặc
  • đúng một tham chiếu khả biến (&mut T),

nhưng không được cả hai. Và mọi tham chiếu phải luôn trỏ tới dữ liệu hợp lệ.

Vì sao? Nếu ai đó có thể thay đổi dữ liệu trong khi người khác đang đọc nó, bạn sẽ gặp những bug như một vector chuyển vùng nhớ trong khi bạn đang giữ con trỏ vào bên trong nó. Giữa các luồng, đó chính xác là data race (tranh chấp dữ liệu). Rust ngăn chặn điều đó lúc biên dịch. Từ phiên bản (edition) 2018, trình biên dịch thông minh hơn về chuyện khi nào một lần mượn kết thúc (gọi là non-lexical lifetimes): một lần mượn chỉ kéo dài đến lần dùng cuối cùng, không phải đến cuối khối.

Tìm hiểu thêm: Mượn.

Lifetime (vòng đời) là cái tên trình biên dịch dùng cho “một tham chiếu hợp lệ trong bao lâu”. Tham chiếu nào cũng có một lifetime. Phần lớn thời gian, trình biên dịch tự suy ra chúng (gọi là lifetime elision, lược bỏ lifetime), nên bạn không bao giờ thấy chúng.

Bạn chỉ phải viết chúng khi trình biên dịch không biết tham chiếu đầu ra liên quan thế nào tới các đầu vào, như một hàm trả về một trong hai chuỗi được mượn:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let first = String::from("borrow checker");
let second = String::from("lifetimes");
println!("{}", longest(&first, &second));
}

Kết quả:

borrow checker
▶ Tự chạy thử

(Hai chuỗi được giữ bằng tiếng Anh vì hàm so sánh độ dài theo byte.) Một điểm quan trọng nên nói khi phỏng vấn: chú thích lifetime không thay đổi thời gian sống của bất cứ thứ gì. Chúng chỉ mô tả các mối quan hệ, để trình biên dịch kiểm tra rằng không tham chiếu nào sống lâu hơn dữ liệu mà nó trỏ tới.

Tìm hiểu thêm: Lifetime.

Hỏi: Rust không có null. Vậy nó dùng gì thay thế?

Phần tiêu đề “Hỏi: Rust không có null. Vậy nó dùng gì thay thế?”

Option<T>, một enum có hai biến thể: Some(value) hoặc None. Vì nó là kiểu khác với T, trình biên dịch buộc bạn xử lý trường hợp None trước khi dùng được giá trị. Bạn không thể quên kiểm tra null, vì code sẽ không biên dịch được.

fn find_user(id: u32) -> Option<&'static str> {
match id {
1 => Some("Ana"),
2 => Some("Ben"),
_ => None,
}
}
fn main() {
for id in [1, 3] {
match find_user(id) {
Some(name) => println!("Tìm thấy {name}"),
None => println!("Không có người dùng nào có id {id}"),
}
}
}

Kết quả:

Tìm thấy Ana
Không có người dùng nào có id 3
▶ Tự chạy thử

Một chi tiết thú vị nên kể thêm: Option<&T> và Option<Box<T>> không tốn thêm bộ nhớ nào so với một con trỏ thường. Bên trong, Rust dùng mẫu bit “null” để biểu diễn None, nên sự an toàn ở đây chẳng tốn gì cả.

Tìm hiểu thêm: Option, If Let.

Hỏi: Trait là gì? Có giống interface không?

Phần tiêu đề “Hỏi: Trait là gì? Có giống interface không?”

Trait định nghĩa một tập các phương thức mà một kiểu có thể triển khai. Nó giống interface trong Java, C# hay Go, và đó là một cách tốt để mở đầu câu trả lời. Sau đó hãy nêu những điểm khác:

  • Trait có thể có phần triển khai mặc định cho phương thức.
  • Bạn có thể triển khai một trait cho một kiểu sau khi kiểu đó đã được viết, kể cả cho những kiểu không phải do bạn viết, như triển khai trait của riêng bạn cho i32. (“Orphan rule”, quy tắc mồ côi, nói rằng hoặc trait hoặc kiểu phải là của bạn.)
  • Trait được dùng làm ràng buộc cho generic (T: Display), được giải quyết lúc biên dịch, và cũng làm trait object (dyn Display) lúc chạy.
  • Rust không có kế thừa lớp. Trait cộng với composition (kết hợp) thay thế cho nó.

Tìm hiểu thêm: Trait, Các trait thông dụng.

Cả hai đều cho phép một hàm làm việc với nhiều kiểu. Khác biệt là kiểu được quyết định khi nào:

  • Generic (fn f<T: Speak>(x: &T)) dùng static dispatch (điều phối tĩnh). Trình biên dịch tạo một bản sao riêng của hàm cho mỗi kiểu. Lời gọi nhanh như lời gọi hàm bình thường, nhưng chương trình có thể lớn hơn.
  • Trait object (&dyn Speak, Box<dyn Speak>) dùng dynamic dispatch (điều phối động). Chỉ một bản sao của hàm, và phương thức đúng được tra cứu lúc chạy qua một bảng (một “vtable”). Chậm hơn một chút xíu, nhưng nó cho phép bạn trộn các kiểu khác nhau trong cùng một collection.
trait Speak {
fn speak(&self) -> String;
}
struct Dog;
struct Cat;
impl Speak for Dog {
fn speak(&self) -> String {
String::from("Gâu gâu")
}
}
impl Speak for Cat {
fn speak(&self) -> String {
String::from("Meo meo")
}
}
fn speak_static<T: Speak>(animal: &T) -> String {
animal.speak()
}
fn speak_dynamic(animal: &dyn Speak) -> String {
animal.speak()
}
fn main() {
println!("{}", speak_static(&Dog));
println!("{}", speak_dynamic(&Cat));
// Chỉ trait object mới trộn được các kiểu trong một Vec:
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &animals {
println!("{}", animal.speak());
}
}

Kết quả:

Gâu gâu
Meo meo
Gâu gâu
Meo meo
▶ Tự chạy thử

Kinh nghiệm chung: hãy bắt đầu với generic. Dùng trait object khi bạn cần một collection gồm các kiểu khác nhau, hoặc muốn giảm thời gian biên dịch và kích thước file chạy.

Tìm hiểu thêm: Generic, Trait object.

Hỏi: Box<dyn Trait> khác impl Trait thế nào?

Phần tiêu đề “Hỏi: Box<dyn Trait> khác impl Trait thế nào?”

Cả hai đều cho phép bạn nói “một kiểu nào đó triển khai trait này” mà không cần gọi tên nó. Khác biệt là được phép có bao nhiêu kiểu cụ thể:

  • impl Trait nghĩa là một kiểu cụ thể mà trình biên dịch biết nhưng không bắt bạn phải viết ra. Đó là static dispatch: không cấp phát trên heap, không vtable, nhanh như khi tự ghi tên kiểu. Ở vị trí tham số (fn f(x: impl Display)), nó chỉ là cách viết ngắn gọn của một generic. Ở vị trí trả về, đó là cách trả về những thứ có kiểu dài dòng hoặc không gọi tên được, như closure và chuỗi iterator.
  • Box<dyn Trait> là một trait object trên heap. Kiểu thật có thể khác nhau mỗi lần, được quyết định lúc chạy, và phương thức được tìm qua vtable. Việc đó tốn một lần cấp phát và một lời gọi gián tiếp, nhưng đó là cách duy nhất để trả về một trong nhiều kiểu, hoặc lưu chúng trong cùng một Vec.
use std::fmt::Display;
// impl Trait: một kiểu ẩn, cố định lúc biên dịch.
fn evens(limit: u32) -> impl Iterator<Item = u32> {
(0..limit).filter(|n| n % 2 == 0)
}
// Box<dyn Trait>: kiểu thật có thể thay đổi lúc chạy.
fn label(as_number: bool) -> Box<dyn Display> {
if as_number {
Box::new(42)
} else {
Box::new("bốn mươi hai")
}
}
fn main() {
let v: Vec<u32> = evens(10).collect();
println!("{v:?}");
println!("{}", label(true));
println!("{}", label(false));
}

Kết quả:

[0, 2, 4, 6, 8]
42
bốn mươi hai
▶ Tự chạy thử

Một chi tiết hay nên nhắc tới: nếu bạn đổi label thành trả về impl Display và bỏ các Box đi, code sẽ không biên dịch được. Nhánh if trả về một i32 còn nhánh else trả về một &str, mà impl Trait phải là một kiểu duy nhất. Kinh nghiệm chung: mặc định dùng impl Trait, và chuyển sang Box<dyn Trait> khi bạn thật sự cần chọn giữa các kiểu lúc chạy.

Tìm hiểu thêm: Trait object, Generic.

Hỏi: Vì sao match phải xét hết mọi trường hợp?

Phần tiêu đề “Hỏi: Vì sao match phải xét hết mọi trường hợp?”

Một match phải xử lý mọi giá trị có thể có, nếu không code sẽ không biên dịch được. Điều này nghĩa là khi bạn thêm một biến thể mới vào enum, trình biên dịch chỉ cho bạn mọi chỗ cần cập nhật. Ở các ngôn ngữ khác, một case bị quên trong switch sẽ lặng lẽ chẳng làm gì.

Bạn có thể dùng _ làm nhánh “bắt tất cả”, nhưng hãy cẩn thận: nhánh bắt tất cả cũng che đi các biến thể mới mà bạn thêm về sau, nên bạn mất tấm lưới an toàn đó.

Tìm hiểu thêm: Match, So khớp mẫu, Enum.

Hỏi: Rust xử lý lỗi thế nào khi không có exception?

Phần tiêu đề “Hỏi: Rust xử lý lỗi thế nào khi không có exception?”

Lỗi có thể khắc phục là các giá trị bình thường có kiểu Result<T, E>: hoặc Ok(value), hoặc Err(error). Một hàm có thể thất bại sẽ nói rõ điều đó trong kiểu trả về, và bên gọi buộc phải xử lý. Không có gì âm thầm nhảy ngược lên ngăn xếp gọi hàm như exception.

Lợi ích: nhìn chữ ký hàm là bạn biết nó có thể thất bại, và trình biên dịch cảnh báo nếu bạn bỏ qua một Result. Cái giá là phải gõ thêm một chút, và toán tử ? gần như xóa bỏ phần đó.

Tìm hiểu thêm: Result.

Hỏi: Khi nào nên dùng panic! thay vì trả về Result?

Phần tiêu đề “Hỏi: Khi nào nên dùng panic! thay vì trả về Result?”
  • Trả về Result khi thất bại là điều có thể lường trước và bên gọi có thể xử lý một cách hợp lý: thiếu file, người dùng nhập sai, mạng quá thời gian chờ.
  • Panic khi đó là một bug, nghĩa là chương trình đã rơi vào trạng thái “lẽ ra không thể xảy ra”, và chạy tiếp sẽ là sai. Truy cập vượt quá cuối một vector sẽ panic vì lý do này.

Panic cũng ổn trong test, ví dụ và bản mẫu làm nhanh. Trong một thư viện, hãy tránh panic với dữ liệu đầu vào mà bên gọi kiểm soát. Hãy trả về lỗi và để họ quyết định.

Tìm hiểu thêm: Panic, Result.

Hỏi: unwrap, expect và ? khác nhau thế nào?

Phần tiêu đề “Hỏi: unwrap, expect và ? khác nhau thế nào?”
  • unwrap() cho bạn giá trị bên trong Ok/Some, hoặc panic với một thông báo chung chung.
  • expect("msg") làm điều tương tự, nhưng panic với thông báo của bạn. Hãy dùng nó để giải thích vì sao bạn tin rằng nó không thể thất bại.
  • ? không panic. Gặp Err/None, nó trả lỗi về từ hàm hiện tại, để bên gọi xử lý. Bản thân hàm phải trả về Result hoặc Option.
use std::num::ParseIntError;
fn double(text: &str) -> Result<i32, ParseIntError> {
let n: i32 = text.parse()?; // khi có lỗi, trả nó về cho bên gọi
Ok(n * 2)
}
fn main() {
println!("{:?}", double("21"));
println!("{:?}", double("abc"));
let n: i32 = "7".parse().expect("một con số viết cứng trong code thì luôn phân tích được");
println!("{n}");
}

Kết quả:

Ok(42)
Err(ParseIntError { kind: InvalidDigit })
7
▶ Tự chạy thử

Trong code chạy thật (production), hãy ưu tiên ?. Dùng expect khi thất bại sẽ là một bug, và để dành unwrap cho test và những thử nghiệm nhanh.

Tìm hiểu thêm: Result, Option.

Hỏi: Làm sao tạo kiểu lỗi của riêng bạn?

Phần tiêu đề “Hỏi: Làm sao tạo kiểu lỗi của riêng bạn?”

Thường là bằng một enum liệt kê các cách code của bạn có thể thất bại, cộng với phần triển khai std::fmt::Display và std::error::Error. Triển khai From cho các kiểu lỗi khác giúp ? tự động chuyển đổi chúng.

Trong thực tế, nhiều dự án dùng các crate trợ giúp: thiserror (tiếng Anh) để định nghĩa kiểu lỗi trong thư viện, và anyhow (tiếng Anh) cho các ứng dụng chỉ cần chuyển lỗi lên trên kèm ngữ cảnh. Với chương trình nhỏ, Box<dyn Error> có thể chứa bất kỳ lỗi nào.

Tìm hiểu thêm: Lỗi tự định nghĩa.

Hỏi: Khi nào dùng anyhow, khi nào dùng thiserror?

Phần tiêu đề “Hỏi: Khi nào dùng anyhow, khi nào dùng thiserror?”

Chúng giải quyết hai nửa của cùng một vấn đề, nên câu trả lời thật lòng là “thường dùng cả hai”:

  • thiserror dành cho thư viện, hoặc bất kỳ code nào mà bên gọi cần phản ứng với từng lỗi cụ thể. Bạn viết enum lỗi của riêng mình và #[derive(Error)] lo phần code lặp lại của Display và Error. Bên gọi nhận được một kiểu thật để match, như ConfigError::MissingField.
  • anyhow dành cho ứng dụng, nơi phần lớn lỗi chỉ cần được báo cáo, không cần xử lý từng cái. Mọi hàm trả về anyhow::Result<T>, ? chấp nhận gần như mọi kiểu lỗi, và .context("while reading config") thêm một lời giải thích dễ hiểu lên trên. Cái giá phải trả: bên gọi không còn dễ dàng match trên lỗi nữa.

Cách sắp xếp phổ biến là dùng thiserror trong các crate thư viện và anyhow trong main cùng phần còn lại của chương trình. Không crate nào là phép màu. Cả hai chỉ bỏ bớt code lặp lại bên trên trait chuẩn std::error::Error, nên lúc nào bạn cũng có thể tự viết tay điều tương tự.

Tìm hiểu thêm: anyhow và thiserror, Lỗi tự định nghĩa.

Hỏi: Rust có bộ thu gom rác (garbage collector) không?

Phần tiêu đề “Hỏi: Rust có bộ thu gom rác (garbage collector) không?”

Không. Bộ nhớ được giải phóng khi chủ sở hữu của nó ra khỏi phạm vi, tại một điểm mà trình biên dịch biết trước (gọi là RAII, và phương thức dọn dẹp là Drop). Điều đó cho bạn hiệu năng dễ đoán trước, không có những khoảng dừng do GC.

Khi bạn thật sự cần quyền sở hữu chung, bạn chủ động chọn đếm tham chiếu (Rc hoặc Arc), cơ chế này giải phóng giá trị khi chủ sở hữu cuối cùng biến mất.

Tìm hiểu thêm: Quyền sở hữu, Con trỏ thông minh.

  • Box<T>: một chủ sở hữu, giá trị nằm trên heap. Dùng cho kiểu đệ quy (như cây hoặc danh sách liên kết), giá trị lớn, hoặc trait object (Box<dyn Trait>).
  • Rc<T>: nhiều chủ sở hữu trong một luồng. Nó đếm số tham chiếu và giải phóng giá trị khi số đếm về 0. Dữ liệu chỉ đọc.
  • Arc<T>: giống Rc, nhưng số đếm là atomic, nên an toàn khi chia sẻ giữa các luồng. Chậm hơn Rc một chút.
  • RefCell<T>: cho phép bạn thay đổi dữ liệu qua một tham chiếu dùng chung (“interior mutability”, tính khả biến bên trong). Các quy tắc mượn được kiểm tra lúc chạy thay vì lúc biên dịch, và vi phạm chúng sẽ gây panic.

Chúng kết hợp được với nhau. Rc<RefCell<T>> là “dùng chung và thay đổi được, trong một luồng”. Arc<Mutex<T>> là phiên bản an toàn với luồng.

use std::cell::RefCell;
use std::rc::Rc;
fn main() {
let shared = Rc::new(RefCell::new(vec![1, 2]));
let other_owner = Rc::clone(&shared);
other_owner.borrow_mut().push(3);
println!("{:?}", shared.borrow());
println!("số chủ sở hữu: {}", Rc::strong_count(&shared));
}

Kết quả:

[1, 2, 3]
số chủ sở hữu: 2
▶ Tự chạy thử

Tìm hiểu thêm: Con trỏ thông minh.

Hỏi: Có thể rò rỉ bộ nhớ trong Rust an toàn không?

Phần tiêu đề “Hỏi: Có thể rò rỉ bộ nhớ trong Rust an toàn không?”

Có, và thành thật nói ra điều đó là tốt. Rust đảm bảo an toàn bộ nhớ (không dùng sau khi giải phóng, không con trỏ lơ lửng, không data race), nhưng rò rỉ bộ nhớ không bị coi là không an toàn. Hai cách thường gặp:

  • Một vòng tham chiếu Rc: hai giá trị trỏ tới nhau bằng Rc sẽ không bao giờ có số đếm về 0. Cách sửa là cho một chiều thành tham chiếu Weak.
  • Các hàm như Box::leak và std::mem::forget, cố tình gây rò rỉ.

Trong code hằng ngày, rò rỉ rất hiếm, vì quyền sở hữu tự động giải phóng gần như mọi thứ.

Tìm hiểu thêm: Con trỏ thông minh.

Hỏi: unsafe là gì, và được dùng khi nào?

Phần tiêu đề “Hỏi: unsafe là gì, và được dùng khi nào?”

unsafe đánh dấu một khối (hoặc một hàm) nơi bạn được làm thêm năm việc mà trình biên dịch không kiểm tra được, như giải tham chiếu (dereference) con trỏ thô, gọi các hàm unsafe (kể cả code C qua FFI), hoặc truy cập biến static khả biến. Bên trong đó, chính bạn hứa rằng các quy tắc được tuân thủ.

Quan trọng: unsafe không tắt borrow checker hay các phép kiểm tra khác. Nó chỉ mở khóa vài thao tác bổ sung đó.

Nó được dùng để giao tiếp với thư viện C, viết các cấu trúc dữ liệu cấp thấp, và vắt thêm hiệu năng ở những chỗ trình biên dịch không chứng minh được là an toàn. Chính thư viện chuẩn cũng dùng unsafe bên trong các API an toàn như Vec. Thói quen tốt: giữ các khối unsafe thật nhỏ, bọc chúng trong một API an toàn, và viết một chú thích // SAFETY: giải thích vì sao nó đúng. Phần lớn code ứng dụng không bao giờ cần đến nó.

Tìm hiểu thêm: Unsafe Rust.

Chúng là các trait đánh dấu mà trình biên dịch dùng để giữ an toàn cho các luồng:

  • Send: một giá trị kiểu này có thể được move sang luồng khác.
  • Sync: một giá trị kiểu này có thể được chia sẻ qua tham chiếu giữa các luồng (&T là Send).

Phần lớn các kiểu là cả hai, và trình biên dịch tự triển khai chúng. Ngoại lệ nổi tiếng là Rc, vì số đếm của nó không an toàn với luồng. Thử gửi một Rc sang luồng khác, code sẽ không biên dịch được:

use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(5);
let handle = thread::spawn(move || {
println!("{data}");
});
handle.join().unwrap();
}
error[E0277]: `Rc<i32>` cannot be sent between threads safely
...
= help: within `{closure@src/main.rs:6:32: 6:39}`, the trait `Send` is not implemented for `Rc<i32>`

Thông báo nói rằng Rc<i32> không thể được gửi giữa các luồng một cách an toàn. Đổi Rc thành Arc là chạy được. Đây là một phần lớn của điều mà người ta gọi là “fearless concurrency” (lập trình đồng thời không sợ hãi) của Rust: lỗi về an toàn luồng trở thành lỗi biên dịch.

Tìm hiểu thêm: Đồng thời.

Hỏi: Làm sao chia sẻ dữ liệu thay đổi được giữa các luồng?

Phần tiêu đề “Hỏi: Làm sao chia sẻ dữ liệu thay đổi được giữa các luồng?”

Câu trả lời kinh điển là Arc<Mutex<T>>. Arc cho phép nhiều luồng cùng sở hữu giá trị. Mutex đảm bảo mỗi lúc chỉ một luồng được thay đổi nó: bạn gọi .lock(), và nhận về một “guard” tự động mở khóa khi ra khỏi phạm vi.

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Tổng: {}", *counter.lock().unwrap());
}

Kết quả:

Tổng: 4000
▶ Tự chạy thử

Hãy nhắc thêm cách thay thế: thay vì chia sẻ bộ nhớ, gửi thông điệp giữa các luồng bằng channel (std::sync::mpsc). Và với các bộ đếm đơn giản, các kiểu atomic như AtomicUsize giúp tránh dùng khóa.

Tìm hiểu thêm: Đồng thời.

Hỏi: Channel so với trạng thái chia sẻ bằng Mutex thì thế nào?

Phần tiêu đề “Hỏi: Channel so với trạng thái chia sẻ bằng Mutex thì thế nào?”

Đó là hai phong cách chia việc giữa các luồng:

  • Channel (std::sync::mpsc) truyền thông điệp. Một giá trị được move vào channel và đi ra ở đầu bên kia, nên mỗi lúc chỉ một luồng sở hữu nó. Chúng hợp với dây chuyền xử lý và nhóm worker: “các worker tạo ra kết quả, một luồng thu gom lại”. Chúng thường dễ suy luận hơn, vì không có hai luồng nào đụng vào cùng một dữ liệu.
  • Trạng thái chia sẻ (Arc<Mutex<T>>) nghĩa là nhiều luồng dùng cùng một dữ liệu, lần lượt đến lượt qua một khóa. Nó hợp khi nhiều luồng cần đọc và cập nhật một cấu trúc chung, như bộ nhớ đệm (cache) hay một bảng bộ đếm. Rủi ro là deadlock (khóa chết), và chậm đi khi các luồng cứ phải chờ khóa.

Đây là bộ đếm ở câu trả lời trước, viết lại bằng channel. Mỗi worker tự đếm riêng, không cần khóa, rồi gửi kết quả của mình:

use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
for _ in 0..4 {
let tx = tx.clone();
thread::spawn(move || {
let mut count = 0;
for _ in 0..1000 {
count += 1;
}
tx.send(count).unwrap();
});
}
drop(tx); // drop bên gửi ban đầu, nếu không rx.iter() sẽ không bao giờ kết thúc
let total: i32 = rx.iter().sum();
println!("Tổng: {total}");
}

Kết quả:

Tổng: 4000
▶ Tự chạy thử

Dòng drop(tx) là một chi tiết rất đáng nhắc tới: rx.iter() cứ chờ mãi cho đến khi mọi bên gửi đều biến mất, nên quên dòng này sẽ khiến chương trình bị treo. Với cả hai phong cách, Rust kiểm tra an toàn luồng lúc biên dịch. Việc lựa chọn là xem thiết kế nào rõ ràng hơn cho bài toán của bạn, và nhiều chương trình dùng cả hai.

Tìm hiểu thêm: Channel, Đồng thời.

Hỏi: Rust có ngăn được mọi bug về đồng thời không?

Phần tiêu đề “Hỏi: Rust có ngăn được mọi bug về đồng thời không?”

Không, và nói được điều đó cho thấy bạn thật sự hiểu. Rust an toàn ngăn được data race (hai luồng cùng đụng vào một vùng nhớ cùng lúc, với ít nhất một luồng ghi). Nó không ngăn được:

  • Deadlock, ví dụ hai luồng mỗi bên chờ một khóa mà bên kia đang giữ.
  • Race condition trong logic của bạn, ví dụ kiểm tra một giá trị rồi hành động dựa trên nó sau khi một luồng khác đã thay đổi nó.

Vậy Rust loại bỏ cả một nhóm bug khó, nhưng bạn vẫn cần thiết kế việc khóa thật cẩn thận.

Tìm hiểu thêm: Đồng thời.

  • Thread (luồng) do hệ điều hành chạy. Mỗi luồng có ngăn xếp riêng, và hệ điều hành chuyển qua lại giữa chúng. Chúng rất hợp cho việc nặng về CPU. Nhưng hàng nghìn luồng sẽ tốn rất nhiều bộ nhớ.
  • Các tác vụ async nhẹ hơn nhiều. Một async fn trả về một Future, và một runtime (thường là Tokio) chạy nhiều future trên vài luồng. Khi một tác vụ đang chờ (mạng, bộ hẹn giờ, file), .await để các tác vụ khác chạy. Nó lý tưởng cho các chương trình phải chờ nhiều, như web server xử lý hàng nghìn kết nối.
use tokio::time::{sleep, Duration};
async fn fetch(name: &str, ms: u64) -> String {
sleep(Duration::from_millis(ms)).await; // giả vờ chờ mạng
format!("{name} đã xong")
}
#[tokio::main]
async fn main() {
// Cả hai chạy cùng lúc, nên mất khoảng 200ms, không phải 300ms.
let (a, b) = tokio::join!(fetch("tác vụ thứ nhất", 200), fetch("tác vụ thứ hai", 100));
println!("{a}");
println!("{b}");
}

Kết quả:

tác vụ thứ nhất đã xong
tác vụ thứ hai đã xong
▶ Tự chạy thử

Hai chi tiết thật lòng mà người phỏng vấn thích nghe: future trong Rust là “lười”, chẳng làm gì cho đến khi được await, và thư viện chuẩn không có sẵn runtime, nên bạn tự chọn một cái. Ngoài ra, code chặn (blocking) như một phép tính dài hay std::thread::sleep bên trong một tác vụ async sẽ chặn luôn các tác vụ khác trên luồng đó.

Tìm hiểu thêm: Async/Await, Đồng thời.

  • Bắt đầu đơn giản, rồi đi sâu dần. Đưa ra câu trả lời một câu trước (“Rc là con trỏ đếm tham chiếu cho quyền sở hữu chung trong một luồng”). Sau đó thêm chi tiết nếu họ có vẻ quan tâm.
  • Giải thích lý do. Các quy tắc của Rust tồn tại để ngăn những bug cụ thể. Nói “quy tắc mượn ngăn data race và con trỏ lơ lửng” mạnh hơn nhiều so với chỉ đọc thuộc lòng các quy tắc.
  • Dùng một ví dụ nhỏ. Viết ba dòng code, hoặc mô tả chúng, thường giải thích được nhiều hơn một bài nói dài.
  • Thành thật về sự đánh đổi. Rust có cái giá của nó: biên dịch chậm hơn, phải mất công học, phải gõ nhiều hơn cho xử lý lỗi. Thừa nhận điều đó cho thấy kinh nghiệm, không phải điểm yếu.
  • Nói “tôi không biết” khi bạn không biết, rồi suy luận thành lời. “Tôi chưa dùng Pin trực tiếp, nhưng tôi biết nó liên quan đến những future không được phép di chuyển trong bộ nhớ” là một câu trả lời tuyệt vời.
  • Chuẩn bị sẵn một câu chuyện. Người phỏng vấn hay hỏi: “Kể về một lần borrow checker chặn bạn lại.” Hãy nghĩ sẵn một ví dụ thật từ code của bạn, và điều bạn học được từ đó.
  • Luyện tập bằng cách viết code. Trang Bài tập, cùng các dự án Trò chơi đoán số và Ứng dụng To-Do là những bài khởi động tốt.

Kiểm tra nhanh

1. Bạn sẽ dùng con trỏ thông minh nào để chia sẻ dữ liệu chỉ đọc giữa các luồng?

2. Toán tử ? làm gì khi gặp một Err?

3. Rust an toàn ngăn được điều nào dưới đây lúc biên dịch?

4. Chú thích lifetime có thay đổi thời gian sống của một giá trị không?

Điểm: 0 / 4

  • The Rust Programming Language (the Rust Book) (tiếng Anh): cuốn sách chính thức. Phần lớn câu trả lời trên trang này đến thẳng từ các chương về quyền sở hữu, trait, xử lý lỗi và lập trình đồng thời của nó.
  • Rust by Example (tiếng Anh): các ví dụ ngắn chạy được, tiện để ôn lại cú pháp ngay trước buổi phỏng vấn.
  • The Rust Reference (tiếng Anh): các quy tắc chính xác của ngôn ngữ, cho lúc người phỏng vấn hỏi “nhưng chính xác thì khi nào chuyện đó xảy ra?”
  • The Rustonomicon (tiếng Anh): hướng dẫn về unsafe Rust, với nền tảng sâu hơn về Send, Sync và cách bố trí bộ nhớ cho các câu hỏi cấp senior.
  • Asynchronous Programming in Rust (tiếng Anh): future, async/.await và runtime gắn kết với nhau thế nào, trong cuốn sách async chính thức của dự án Rust.