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

Phạm vi (scope) trong Rust

Phạm vi (scope) là vùng code mà trong đó một biến còn hợp lệ. Ý tưởng thì đơn giản, nhưng hiểu được nó sẽ giúp rất nhiều quy tắc khác của Rust (đặc biệt là quyền sở hữu) trở nên dễ hiểu.

Phạm vi được tạo bởi dấu ngoặc nhọn

Phần tiêu đề “Phạm vi được tạo bởi dấu ngoặc nhọn”

Một biến tồn tại từ chỗ nó được khai báo cho đến dấu } đóng của khối chứa nó:

fn main() {
let x = 5; // x bắt đầu có phạm vi từ đây
{
let y = 10; // y bắt đầu có phạm vi từ đây
println!("x = {x}, y = {y}"); // cả hai đều hợp lệ
} // y ra khỏi phạm vi tại đây
println!("x = {x}");
// println!("{y}"); // ❌ lỗi: y không còn trong phạm vi
}

Kết quả:

x = 5, y = 10
x = 5
▶ Tự chạy thử

y chỉ tồn tại bên trong khối { } bên trong. Khi khối đó kết thúc, y biến mất — cố dùng nó sau đó là lỗi biên dịch, chứ không phải một bất ngờ lúc chạy.

Phạm vi chỉ đi một chiều: khối bên trong nhìn thấy được biến của các khối bao quanh nó (vì thế khối bên trong in được x), nhưng khối bên ngoài không nhìn vào được khối bên trong.

Tham số và mọi biến bạn tạo bên trong một hàm chỉ tồn tại trong suốt lần gọi hàm đó:

fn calculate() -> i32 {
let result = 5 * 2; // result chỉ tồn tại bên trong calculate
result
}
fn main() {
let value = calculate();
println!("{value}");
// println!("{result}"); // ❌ lỗi: result không tồn tại ở ngoài này
}

Kết quả:

10
▶ Tự chạy thử

Chiều ngược lại cũng vậy: một hàm không nhìn thấy được biến cục bộ của main. Nếu nó cần một giá trị, bạn truyền vào dưới dạng tham số, như bạn đã thấy trong bài Hàm.

Một khối { } không chỉ giới hạn nơi biến sống. Nó còn có thể tạo ra một giá trị: biểu thức cuối cùng bên trong nó, viết không có dấu chấm phẩy. Đó chính là quy tắc khiến một hàm trả về dòng cuối cùng của nó.

Điều này rất tiện khi bạn cần vài biến phụ để tính toán gì đó, nhưng không muốn chúng còn lảng vảng sau đó:

fn main() {
let price = 40;
let total = {
let tax = price / 10; // tax chỉ sống bên trong khối này
let shipping = 5;
price + tax + shipping // không có dấu chấm phẩy: đây là giá trị của khối
};
println!("total = {total}");
// println!("{tax}"); // ❌ lỗi: tax đã ra khỏi phạm vi
}

Kết quả:

total = 49
▶ Tự chạy thử

Hai chi tiết cần để ý:

  • Dòng cuối price + tax + shipping không có dấu chấm phẩy. Nếu bạn thêm vào, khối sẽ trả về () (không có gì) thay vì một con số, và println! không in được nó.
  • Toàn bộ là một câu lệnh let, nên nó kết thúc bằng }; sau dấu ngoặc nhọn đóng.

Khi khối kết thúc, tax và shipping biến mất. Chỉ còn lại kết quả, total. Giữ các biến phụ trong một khối nhỏ như vậy cho thấy rõ chúng chỉ cần cho một phép tính.

Vì sao phạm vi quan trọng với quyền sở hữu

Phần tiêu đề “Vì sao phạm vi quan trọng với quyền sở hữu”

Điều này liên quan trực tiếp đến quyền sở hữu (ownership), thứ bạn sẽ gặp ở phần sau: một giá trị được tự động dọn dẹp ngay khi phạm vi của chủ sở hữu nó kết thúc.

fn main() {
{
let text = String::from("xin chào");
println!("{text}");
} // phạm vi của text kết thúc tại đây — Rust tự động drop nó
}

Kết quả:

xin chào
▶ Tự chạy thử

Đó là toàn bộ cơ chế giúp Rust không cần bộ thu gom rác (garbage collector): ngay lúc biên dịch, nó đã biết chính xác mỗi phạm vi kết thúc ở đâu.

Bạn còn nhớ shadowing (che khuất) trong bài Biến chứ? Mỗi let cùng tên bên trong một phạm vi tạo ra một biến riêng — nó không xóa biến bên ngoài, chỉ thay thế biến đó trong phần còn lại của phạm vi:

fn main() {
let x = 5;
{
let x = x * 2; // một x mới, chỉ tồn tại bên trong khối này
println!("x bên trong = {x}"); // 10
}
println!("x bên ngoài = {x}"); // 5 — không bị ảnh hưởng
}

Kết quả:

x bên trong = 10
x bên ngoài = 5
▶ Tự chạy thử

Chương trình này không biên dịch được, vì area được dùng sau khi khối của nó đã kết thúc:

fn main() {
let width = 3;
{
let height = 4;
let area = width * height;
}
println!("area = {area}");
}

Hãy sửa để nó in ra area = 12, trong khi height vẫn chỉ sống bên trong khối. Gợi ý: cho khối tạo ra diện tích, rồi lưu kết quả vào một biến được khai báo bên ngoài nó.

Xem lời giải
fn main() {
let width = 3;
let area = {
let height = 4;
width * height
};
println!("area = {area}");
}

Kết quả:

area = 12
▶ Tự chạy thử

Giờ area sống trong phạm vi của main, nên println! nhìn thấy nó. height vẫn chỉ cần bên trong khối, và bị drop khi khối kết thúc.

Tạo biến level có giá trị 1. Mở một khối che khuất nó bằng 2, và bên trong khối đó, thêm một khối nữa che khuất nó bằng 3. In level trong mỗi khối, và in lại ngay sau khi mỗi khối đóng. Gợi ý: bạn sẽ thấy các số đi 1, 2, 3, rồi lùi về 2 và 1.

Xem lời giải
fn main() {
let level = 1;
println!("tầng {level}");
{
let level = 2;
println!("tầng {level}");
{
let level = 3;
println!("tầng {level}");
}
println!("quay về tầng {level}");
}
println!("quay về tầng {level}");
}

Kết quả:

tầng 1
tầng 2
tầng 3
quay về tầng 2
quay về tầng 1
▶ Tự chạy thử

Mỗi level bên trong chỉ che khuất biến bên ngoài cho đến khi khối của nó kết thúc. Các biến bên ngoài chưa từng bị thay đổi, nên chúng vẫn ở đó, chờ sẵn.

  • Phạm vi là vùng (đánh dấu bởi { }) mà một biến còn hợp lệ.
  • Một biến ngừng tồn tại ngay khi tới dấu } đóng phạm vi của nó.
  • Thân hàm cũng là phạm vi — biến cục bộ trong hàm biến mất khi hàm trả về.
  • Một khối là một biểu thức: dòng cuối của nó (không có dấu chấm phẩy) là giá trị của khối, và các biến phụ biến mất khi khối kết thúc.
  • Phạm vi cho Rust biết chính xác khi nào drop một giá trị, vì thế không cần bộ thu gom rác.

Kiểm tra nhanh

1. Theo bài này, vì sao Rust không cần bộ thu gom rác?

2. Điều gì xảy ra khi bạn che khuất một biến bằng let x = x * 2; bên trong một khối con?

3. Giá trị của total sau let total = { let a = 2; a * 10 }; là gì?

Điểm: 0 / 3