Ownership có gì? Tại sao nó là khái niệm quan trọng trong Rust
Mở đầu
Chào mừng các bạn trở lại với chuỗi bài viết về Rust! Ở bài trước, chúng ta đã tìm hiểu về Variables và Mutability trong Rust. Hôm nay chúng ta sẽ cùng nhau khám phá một trong những khái niệm có thể gọi là quan trọng nhất trong Rust: Ownership. Đừng lo nếu bạn thấy chúng hơi "khoai" - ban đầu mình cũng vậy. Mình sẽ cố gắng giải thích chúng một cách dễ hiểu nhất, và khi đã hiểu rồi, bạn sẽ thấy nó siêu thú vị!
Stack & Heap là gì?
Nếu như bạn học nhiều ngôn ngữ khác có thể sẽ không cần quan tâm về Stack và Heap nhiều. Nhưng với Rust - một ngôn ngữ lập trình hệ thống, thì data lưu ở Stack hay Heap có nhiều sự khác biệt, nên bạn cần bắt buộc hiểu rõ Stack và Heap trước khi tìm hiểu về Ownership nhé.
Stack là gì?
Stack là một vùng nhớ có cấu trúc, hoạt động theo nguyên tắc Last In, First Out (LIFO) - giống như xếp chồng đĩa: đĩa cuối cùng xếp vào sẽ được lấy ra đầu tiên.
Đặc điểm của Stack trong Rust:
1. Kích thước cố định:
-
Dữ liệu trên Stack phải có kích thước biết trước và không thay đổi trong suốt thời gian tồn tại (là dữ liệu nguyên thủy của Rust).
-
Ví dụ: số nguyên (i32), boolean (true/false), hay con trỏ.
2. Truy cập siêu nhanh:
-
Vì Stack quản lý dữ liệu theo thứ tự LIFO, Rust biết chính xác vị trí của từng biến.
-
Thao tác push (thêm dữ liệu) và pop (lấy dữ liệu) rất nhanh, chỉ cần di chuyển con trỏ Stack.
3. Tự động giải phóng:
- Khi biến đi ra khỏi phạm vi (scope), Rust tự động giải phóng bộ nhớ của nó trên Stack.
Heap là gì?
Heap là một vùng nhớ linh hoạt, dùng để lưu trữ dữ liệu có kích thước không cố định hoặc có thể thay đổi trong runtime.
Đặc điểm của Heap trong Rust:
1. Size động:
-
Dữ liệu trên Heap có thể tăng hoặc giảm size (ví dụ: String, Vector).
-
Rust không biết trước kích thước của dữ liệu này tại thời điểm biên dịch.
2. Truy cập chậm hơn:
-
Khi thêm dữ liệu vào Heap, Rust phải tìm một vùng nhớ đủ lớn → đánh dấu vùng nhớ đó là đã dùng → trả về một pointer (con trỏ) để truy cập dữ liệu.
-
Việc truy cập dữ liệu thông qua pointer làm chậm hơn so với Stack.
3. Cần quản lý bộ nhớ:
-
Nếu không giải phóng bộ nhớ Heap sau khi sử dụng, sẽ dẫn đến memory leak (rò rỉ bộ nhớ).
-
Rust giải quyết vấn đề này bằng Ownership, đảm bảo bộ nhớ được giải phóng tự động.
So Sánh Stack và Heap
Đặc Điểm | Stack | Heap |
---|---|---|
Tốc độ | Siêu nhanh 🚀 | Chậm hơn 🐢 |
Kích thước | Cố định và biết trước | Linh hoạt, có thể thay đổi |
Quản lý bộ nhớ | Tự động giải phóng | Cần quản lý (Rust tự động làm) |
Dữ liệu phù hợp | Kiểu nguyên thủy (int, bool, ...) | Kiểu phức tạp (String, Vector, ...) |
Ví dụ | let x = 5; |
let s = String::from("hello"); |
Ví Dụ Thực Tế: Stack và Heap
Stack giống như: Bạn đặt một đống sách lên bàn. Bạn biết chính xác quyển nào ở trên cùng và có thể lấy nó ngay lập tức.
Heap giống như: Bạn là nhân viên trong nhà hàng, xếp khách hàng vào bàn trống và phù hợp với số người. Ví dụ ban đầu khách có 2 người bạn xếp vào bàn 2 người nhưng sau đó số người tăng lên 5 thì bạn cần tìm và chuyển toàn bộ khách và đồ ăn ... sang bàn 6 người và đánh dấu bàn 2 người kia là trống (giải phóng bộ nhớ).
Ownership
Ownership là bộ 3 quy tắc giúp Rust quản lý bộ nhớ an toàn.
Đây có thể là một khái niệm khá mới mẻ với nhiều lập trình viên. Tuy nhiên Ownership cũng không phải do Rust sáng tạo ra hay ứng dụng đầu tiên mà Rust tham khảo từ Cyclone.
Các ngôn ngữ lập trình khác có nhiều cách khác nhau để quản lý bộ nhớ như dùng Garbage Collection (GC) - liên tục quét trong chương trình để kiểm tra vùng nhớ (memory) không được được sử dụng và giải phóng nó như Java, C#, Go ... Hay phải quản lý bộ nhớ thủ công bằng tay như C/C++.
Rust đi theo hướng quản lý bộ nhớ khác, đó là dùng các bộ quy tắc của Ownership được trình biên dịch kiểm tra, chương trình sẽ không biên dịch nếu vi phạm bất kỳ quy tắc nào. Điều đó có nghĩa chỉ cần chương trình đã biên dịch thành công thì trong runtime gần như không thể bị các lỗi liên quan đến quản lý bộ nhớ như memory leaks, dangling pointers, data race,...
Các quy tắc Ownership
- Quy tắc 1: Mỗi giá trị chỉ có 1 owner:
Mỗi giá trị khởi tạo trong Rust chỉ thuộc về một biến duy nhất tại một thời điểm.
fn main() {
let s1 = String::from("hello"); // s1 là owner của "hello"
let s2 = s1; // Quyền sở hữu được chuyển từ s1 sang s2
println!("{}", s1); // ❌ Lỗi! s1 không còn là owner
}
👉 Tại sao? Rust đảm bảo chỉ có một owner để tránh xung đột và lỗi bộ nhớ.
- Quy tắc 2: Chỉ có một owner tại một thời điểm
Khi bạn gán một biến cho một biến khác, quyền sở hữu được chuyển giao, và biến cũ không còn quyền truy cập vào giá trị đó.
fn main() {
let laptop = String::from("MacBook"); // Bạn sở hữu MacBook
let friend = laptop; // Bạn tặng MacBook cho bạn
println!("{}", laptop); // ❌ Lỗi! Bạn không còn MacBook để dùng!
}
👉 Tại sao? Để tránh việc hai biến cùng sửa đổi một giá trị, dẫn đến xung đột dữ liệu.
- Quy tắc 3: Giá trị bị giải phóng khi owner ra khỏi scope
Khi biến đi ra khỏi phạm vi (scope), Rust tự động gọi hàm drop() để giải phóng bộ nhớ của giá trị đó.
fn main() {
{ // Scope mới
let food = String::from("Pizza"); // Pizza được tạo ở đây
} // Pizza bị "ăn" hết (drop) khi ra khỏi scope
println!("{}", food); // ❌ Lỗi! Pizza đã hết
}
👉 Tại sao? Để tránh memory leak (rò rỉ bộ nhớ) khi giá trị không còn được sử dụng.
Nếu các quy tắc trên vẫn hơi khó hiểu với bạn thì chúng ta tiếp tục tìm hiểu những ví dụ bên dưới nhé.
Variables và Data Interacting với Move
Dữ liệu trên Stack: Copy
fn main() {
let x = 5; // x là owner của giá trị 5
let y = x; // Copy giá trị của x sang y
println!("x: {}, y: {}", x, y); // ✅ Cả x và y đều hợp lệ, vì kiểu dữ liệu i32 thực hiện trait Copy.
}
Trông hơi mâu thuẫn với ví dụ ở "Quy tắc 1" bên trên nhỉ. Như đã đề cấp ở trên các kiểu dữ liệu nguyên thủy như bool
, char
, int
... được lưu trên Stack và size đã biết trước ở thời điểm biên dịch nên việc clone giá trị sẽ rất nhanh chóng. Rust cũng không cần dùng Ownership để chuyển quyền cho phức tạp làm gì cả.
Dữ liệu trên Heap: Move
Với dữ liệu phức tạp như chuỗi String
, Vector
...(những kiểu dữ liệu có thể thay đổi size trong runtime), khi gán biến mới, quyền sở hữu được chuyển giao (move) thay vì sao chép.
let s1 = String::from("hello");
let s2 = s1; // s1 bị "move" vào s2
// println!("{}", s1); // Lỗi: s1 không còn hợp lệ, chỉ s2 hợp lệ
Giải thích một chút về cách lưu dữ liệu!
Một String
có 3 phần: pointer, length, và capacity. Chúng được lưu ở Stack (bên trái), còn bên phải là giá trị của biến đó sẽ lưu ở Heap
Khi bạn gán
s2
bằng s1
thực chất chỉ copy pointer, length, và capacity của s1
trong Stack cho s2
mà không phải gán lại cả giá trị của nó ở Heap. Điều này giúp tránh lưu nhiều data không cần thiết và không làm giảm hiệu năng nếu data ở Heap lớn.
Ở nhiều ngôn ngữ khác việc chỉ copy pointer, length, và capacity mà không copy data sẽ được gọi là copy nông (shallow copy) còn copy cả data ở Heap sẽ được gọi là copy sâu (deep copy). Còn trong Rust khi gán s2
bằng s1
ngoài việc copy pointer, length, và capacity thì còn vô hiệu hóa biến đầu tiên (s1) nên được gọi là move. Tức biến s1
đã chuyển giao (move) quyền sỡ hữu cho s2
.
Có thể bạn sẽ thắc mắc tại sao Rust phải làm phức tạp như vậy không? Không phải chỉ cần copy pointer, length, và capacity là được rồi, còn phải vô hiệu biến s1
làm gì nữa. Đương nhiên cái gì cũng có lý do của nó, khi bạn khi gán biến s2
từ s1
thì thực chất 2 con trỏ đang cùng trỏ 1 vùng nhớ trên Heap lưu trữ giá trị của chuỗi. Nếu bạn còn nhớ ở bên trên thì khi biến ra khỏi phạm vi (scope) sẽ được giải phóng (drop), vậy sẽ có 1 vấn đề lớn ở đây là khi cả 2 biến s1
,s2
đều ra khỏi phạm vi, sẽ dẫn đến việc giải phóng bộ nhớ 2 lần sẽ bị lỗi double free. Mà lỗi này ở các ngôn ngữ khác có thể xảy ra trong runtime rất khó debug. Nên Rust đã đảm bảo an toàn bộ nhớ bằng các vô hiệu hóa biến s1
đi.
Ownership và Functions
Khi truyền tham số vào một hàm, quyền sở hữu có thể được chuyển giao (move). Sau khi hàm kết thúc, biến sẽ không còn hợp lệ bên ngoài hàm.
Vẫn tuân theo Ownership bên trên nên nếu bạn truyền 1 tham số có kiểu dữ liệu nguyên thủy thì thì sẽ không bị chuyển giao quyền sở hữu nên vẫn sử dụng bình thường
fn takes_ownership(s: String) {
println!("{}", s);
}
fn main() {
let s = String::from("hello");
takes_ownership(s); // s được "move" vào hàm
// println!("{}", s); // ❌ Lỗi: s không còn hợp lệ
}
Trả về giá trị và chuyển quyền sở hữu
Hàm có thể trả về quyền sở hữu của một giá trị. Điều này cho phép bạn sử dụng lại biến sau khi hàm kết thúc:
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_length(s1);
println!("The length of '{s2}' is {len}.");
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() return length của String
(s, length) // cú pháp return sẽ không có ";"
}
Ownership của một biến tuân theo quy tắc: Khi một biến đi ra khỏi phạm vi, giá trị sẽ được giải phóng trừ khi quyền sở hữu dữ liệu đã được chuyển sang một biến khác.
Việc này khiến mỗi lần gọi hàm lại phải gán cho 1 biến, nó khá phức tạp. Đôi khi chúng ta chỉ muốn dùng giá trị của biến mà không cần chuyển quyền sở hữu có được không. Câu trả lời là được, chúng ta sẽ cùng tìm hiểu về References ở bài viết sau.
Kết Luận
Ownership giống như luật giao thông của Rust: nghiêm khắc nhưng giúp bạn tránh "tai nạn" về bộ nhớ. Ban đầu có thể hơi khó chịu, nhưng khi quen rồi, bạn sẽ thấy nó rất thú vị.
Hy vọng rằng bài viết này sẽ giúp hiểu rõ hơn về Ownership - một tính năng cực quan trọng trong Rust. Nếu có thắc mắc gì, đừng ngại comment ở dưới nhé!
Hãy code thử vài ví dụ, đừng sợ lỗi - Rust sẽ "nói cho bạn biết" cách sửa ngay! Happy Rustaceans!
Tài liệu tham khảo
All rights reserved