Trong việc quản lý bộ nhớ, một ngôn ngữ lập trình lý tưởng sẽ có hai đặc điểm sau:
- Giải phóng bộ nhớ một cách nhanh chóng. Giúp ứng dụng tiêu tốn ít bộ nhớ hơn, hiệu suất sẽ tốt hơn.
- Không trỏ đến các vùng nhớ đã bị giải phóng. Điều này sẽ gây lỗi chương trình hay tạo ra các lỗ hổng bảo mật nghiêm trọng.
Hầu hết các ngôn ngữ lập trình chính hiện nay thuộc một trong hai trường phái, tùy thuộc vào việc chúng ưu tiên đặc điểm nào hơn trong hai đặc điểm trên:
- Trường phái An toàn là trên hết sử dụng cơ chế thu dọn rác garbage collection để quản lý bộ nhớ, tự động giải phóng các đối tượng khi tất cả các con trỏ trỏ đến chúng không còn được sử dụng. Điều này sẽ tránh được việc xuất hiện con trỏ lơ lửng. Hầu hết các ngôn ngữ hiện đại thuộc trường phái này, từ
Python
,JavaScript
,Ruby
đếnJava
,C#
, vàHaskell
.
Con trỏ lơ lửng (dangling pointer) là một con trỏ trỏ đến vùng nhớ đã bị giải phóng hoặc không hợp lệ.
Tuy nhiên, điều đó không có nghĩa là chúng ta phó mặc hết cho garbage collection. Thuật toán của garbage collection có thể gây ra 1 số vấn đề ảnh hưởng đến hiệu năng ... Ví dụ điển hình là việc Discord chuyển từ Go sang Rust do gặp vấn đề với trình garbage collection.
- Trường phái Kiểm soát là trên hết giao toàn bộ quyền cấp phát và giải phóng bộ nhớ cho lập trình viên. Trách nhiệm xử lý con trỏ lơ lửng cũng hoàn toàn do lập trình viên đảm nhận.
C
vàC++
là hai ngôn ngữ tiêu biểu thuộc trường phái này.
1. Ownership là gì ?
Chúng ta có đoạn code C++ sau:
std::string s = "frayed knot";
Ở đây, đối tượng std::string
sẽ có ba thành phần, bao gồm:
- một con trỏ
- dung lượng bộ nhớ tối đa được cấp phát (capacity)
- dung lượng bộ nhớ đang dùng để lưu trữ (length)
Với C++, trình biên dịch cho phép truy cập tới 1 con trỏ khi dữ liệu mà nó trỏ đến đã được giải phóng. Rất nguy hiểm khi lúc đó ta sẽ không kiểm soát được giá trị mà con trỏ sẽ trả về.
#include <iostream> int main() { int* ptr = new int(42); // Cấp phát động một số nguyên delete ptr; // Giải phóng vùng nhớ // Truy cập con trỏ lơ lửng và giá trị in ra có thể là một con số bất kỳ std::cout << *ptr << std::endl; return 0;
}
Rust đề xuất một khái niệm với cái tên là Ownership.
Ownership là các quy tắc đảm bảo an toàn bộ nhớ được tích hợp ngay trong trình biên dịch
Ownership, gồm các quy tắc sau:
- Mọi giá trị đều có một chủ sở hữu duy nhất (owner)
- Chủ sở hữu của giá trị có thể thay đổi nhưng vào một thời điểm bất kỳ thì mỗi giá trị đó chỉ có 1 chủ sở hữu
- Khi owner bị hủy (dropped) thì giá trị được sở hữu cũng sẽ bị hủy theo
Một biến sở hữu giá trị của chính nó. Khi quá trình thực thi ra khỏi khối (block) nơi mà biến đó được khai báo, nó sẽ bị hủy cùng với giá trị mà biến đó lưu trữ.
fn print_padovan() { let mut padovan = vec![1,1,1]; // cấp phát ở đây for i in 3..10 { let next = padovan[i-3] + padovan[i-2]; padovan.push(next);
} println!("P(1..10) = {:?}", padovan);
} // hết block => giải phóng bộ nhớ đã cấp phát cho vector padovan
Tiếp đến chúng ta có một ví dụ phức tạp hơn
struct Person { name: String, birth: i32 } fn main() { let mut composers = Vec::new(); composers.push(Person { name: "Palestrina".to_string(), birth: 1525 }); composers.push(Person { name: "Dowland".to_string(), birth: 1563 }); composers.push(Person { name: "Lully".to_string(), birth: 1632 }); for composer in &composers { println!("{}, born {}", composer.name, composer.birth); }
}
Ở đây có nhiều mối quan hệ sở hữu:
composers
sở hữu một vector;- vector sở hữu các phần tử của nó, mỗi phần tử là một struct
Person
; - mỗi struct Person lại sở hữu các trường của nó
- trường
name
sở hữu chuỗi mà nó trỏ đến.
Nhìn vào hình minh họa, chúng ta có thể thấy mối các quan hệ tạo thành một cấu trúc gia phả dạng cây với composers
là gốc.
Do đó, khi trình thực thi ra khỏi phạm vi mà composers
được định nghĩa, chương trình sẽ giải phóng nó và toàn bộ các biến liên quan.
Tuy nhiên, với chỉ các khái niệm về quyền sở hữu (ownership) như đã nêu ở trên vẫn chưa đủ tính linh hoạt. Rust mở rộng thêm 1 số ý tưởng như sau:
- Có thể chuyển quyền sở hữu sang một chủ sở hữu khác. Điều này cho phép xây dựng, sắp xếp lại, và tháo dỡ cây sở hữu.
- Những kiểu dữ liệu nguyên thủy như số nguyên, số thực, .... không cần tuân thủ các quy tắc ownership. Những kiểu này được gọi là Copy types.
- Thư viện chuẩn cung cấp các kiểu con trỏ như
Rc
hayArc
, cho phép một giá trị có nhiều chủ sở hữu, nhưng với một số hạn chế. - Có thể "mượn tham chiếu" (borrow a reference) tới một giá trị; tham chiếu là các con trỏ không sở hữu (non-owning pointers) với thời gian sống (lifetime) giới hạn.
Mỗi ý tưởng trên đều góp phần mang lại sự linh hoạt cho mô hình sở hữu, trong khi vẫn giữ vững các cam kết về an toàn của Rust. Chúng tôi sẽ đi tìm hiểu chi tiết từng mục ở các phần sau.
2. Moves
Trong ngôn ngữ lập trình, gán giá trị cho một biến, truyền tham số vào hàm, hay trả về giá từ một hàm là những thao tác vô cùng cơ bản. Với Rust, trong những thao tác vô cùng cơ bản đó đồng thời cũng kèm theo việc "chuyển giao" chủ sở hữu của các biến. Việc chuyển giao chủ sở hữu đó được gọi với cái tên là Moves.
Lưu ý: Các quy tắc ownership, moves không áp dụng với các kiểu dữ liệu nguyên thủy như u64, f32, bool, char ...
Cùng xem đoạn mã Python sau:
s = ['udon', 'ramen', 'soba']
t = []
u = []
Hình ảnh mô tả sau khi s được khởi tạo giá trị
- reference count: Số lượng tham chiếu đến (hiện bằng 1 do chỉ có
s
tham chiếu đến) - length: độ lớn của mảng
- capacity: độ lớn được cấp phát cho mảng
Gán cho t
và u
bằng s
. Điều gì sẽ xảy ra ?
s = ['udon', 'ramen', 'soba']
t = s
u = s
Python thực hiện cách đơn giản là sao chép y nguyên giá trị của s
cho t
và u
, đồng thời tăng reference count
lên bằng 3
. Các biến s, t và u
có quyền hạn tương đương nhau trong việc tiếp cận với các phần tử của mảng.
Cùng logic đó nhưng với C++ lại là 1 cách xử lý khác.
using namespace std; vector<string> s = { "udon", "ramen", "soba" };
vector<string> t = s;
vector<string> u = s;
Phép gán trong C++ có thể tiêu tốn bộ nhớ và thời gian xử lý hơn. Tuy nhiên, lợi thế là chương trình có thể dễ dàng quyết định khi nào cần giải phóng bộ nhớ.
Python làm cho phép gán trở nên trông đơn giản hơn nhưng cần phải phụ thuộc vào việc theo dõi liên tục giá trị reference count
để trình dọn rác có thể giải phóng bộ nhớ.
Vậy Rust sẽ xử lý tình huống trên như thế nào ?
fn main() { let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()]; let t = s; let u = s;
}
Code báo lỗi chứ không chạy trơn tru như các ví dụ với Python, C++.
Việc gán let t = s
đã chuyển quyền sở hữu vector từ s
sang t
. Các giá trị của vector vẫn giữ nguyên, không bị ảnh hưởng. Cũng không cần bất cứ reference counts nào như trong Python. Đồng thời, trình biên dịch giờ xem s
như chưa được khởi tạo.
Khi hiện lệnh gán let u = s
.s
đã bị move nên không còn giá trị => trình biên dịch sẽ báo lỗi.
Lưu ý: Nếu muốn copy sâu (deep copy) như ví dụ bằng C++ thì chúng ta sẽ sử dụng hàm clone
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s.clone();
let u = s.clone();
Moves và Luồng điều khiển
let x = vec![10, 20, 30]; if c { f(x); // Chuyển quyền sở hữu x sang cho hàm f(x)
} else { g(x); // Chuyển quyền sở hữu x sang cho hàm g(x)
} h(x);
Phụ thuộc và giá trị của biến c
mà hàm f(x)
hay g(x)
sẽ được gọi. Việc gọi h(x)
ở phía sau sẽ gây ra lỗi do các nguyên nhân sau
- Rust không cho phép sử dụng biến đã bị moves đi chỗ khác (use of moved value)
- Sau khi thực thi hàm
f(x)
hoặcg(x)
, biếnx
đã được giải phóng => Hàmh
ko thể truy cập
let x = vec![10, 20, 30]; while f() { g(x); // Ngay từ vòng lặp đầu tiên thì x đã bị moves đi => Đến vòng lặp thứ 2 sẽ gặp lỗi
}
let mut x = vec![10, 20, 30]; while f() { g(x); // move x tới g(x) x = h(); // Khởi tạo lại x với hàm h
} e(x); // code chạy ko lỗi
Moves và Index
fn main() { let mut v = Vec::new(); for i in 101..106 { v.push(i.to_string()); } let third = v[2]; // Báo lỗi: cannot move out of index of `Vec<String>` let fifth = v[4]; // Lỗi tương tự ở trên
}
Ở 2 dòng cuối, chúng ta cố gắng di chuyển phần từ thứ 3 và thứ 5 của vector sang cho các biến third
và fifth
. Rust không cho phép điều đó xảy ra vì nó sẽ phá vỡ cấu trúc của vector khi v[2] và v[4]
sau đó sẽ không thể truy cập, vector cũng sẽ phải làm cách gì đó để "nhớ" xem phần tử nào còn có thể truy cập 🤒🤕
Nếu thực sự vẫn muốn di chuyển một phần tử ra khỏi vector thì sao? Chúng ta có thể dùng các phương pháp mà chúng không phá vỡ tính cấu trúc của vector.
fn main() { // vector strings "101", "102", ... "105" let mut v = Vec::new(); for i in 101..106 { v.push(i.to_string()); } // Hàm pop lấy ra phần tử cuối và cập nhật lại kích thước vector (self.len -= 1;) let fifth = v.pop().expect("vector empty!"); assert_eq!(fifth, "105"); // Hàm swap_remove chuyển quyền sở hữu v[1] cho second // Và thay thế ngay giá trị mới cho v[1] bằng giá trị phần tử cuối // Có cập nhật lại độ dài vector let second = v.swap_remove(1); assert_eq!(second, "102"); assert_eq!(v, vec!["101", "104", "103"]); // Lấy ra giá trị v[2] và thay thế bằng 1 giá trị khác let third = std::mem::replace(&mut v[2], "substitute".to_string()); assert_eq!(third, "103"); // Giá trị cuối cùng của vector v assert_eq!(v, vec!["101", "104", "substitute"]);
}
Copy Types: Ngoại lệ với các quy tắc Moves
let string1 = "somnambulance".to_string();
let string2 = string1;
let num1: i32 = 36;
let num2 = num1;
Khi string2
được khởi tạo thì cũng là lúc string1
bị vô hiệu hóa. Trong trường hợp với num1
và num2
lại khác. Một giá trị kiểu i32 không sở hữu bất kỳ tài nguyên nào trên heap hoặc phụ thuộc vào bất cứ điều gì ngoài các byte mà nó chứa. Khi gán num2 = num1
, thì num2
trở thành một bản sao hoàn toàn độc lập từ num1
.
string1
không còn hiệu lực sau khi đã chuyển giá trị cho string2
, nó giúp ngăn ngừa lỗi liên quan đến quản lý bộ nhớ. Tuy nhiên, đối với num1
, việc vô hiệu hóa nó là không cần thiết, không có tác hại nào xảy ra nếu tiếp tục sử dụng. Các quy tắc ownership không được áp dụng với các biến được lưu trên stack như num1
.
Các kiểu chuẩn thuộc Copy bao gồm tất cả các kiểu số nguyên và số thực dạng phẩy động, các kiểu char
và bool
, và một số kiểu khác. Một tuple hoặc mảng cố định chứa toàn các kiểu Copy cũng là một kiểu Copy.
Các kiểu có sử dụng vùng nhớ trên heap như String, Box<T>, File, MutexGuard ...
không phải là kiểu Copy.
Vậy còn các kiểu do bạn tự định nghĩa thì sao ? Mặc định, các kiểu struct và enum không thuộc Copy. Nhưng chúng ta có thể cài đặt thêm để nó có hành vi giống kiểu Copy.
struct Label { number: u32,
} fn main() { fn print(l: Label) { println!("STAMP: {}", l.number); } let l = Label { number: 3 }; print(l); // chuyển l tới hàm print println!("My label number is: {}", l.number); // báo lỗi do đã moved l đi chỗ khác
}
#[derive(Copy, Clone)] // thêm thuộc tính để có thể Copy như các kiểu dữ liệu nguyên thủy
struct Label { number: u32,
} fn main() { fn print(l: Label) { println!("STAMP: {}", l.number); } let l = Label { number: 3 }; print(l); println!("My label number is: {}", l.number);
}
Lưu ý: Chỉ có thể áp dụng #[derive(Copy, Clone)]
cho các struct mà các trường của struct đó là kiểu Copy (hoặc có triển khai Copy)
#[derive(Copy, Clone)]
struct Label { name: String } // Chương trình báo lỗi: the trait `Copy` cannot be implemented for this type
fn main() { fn print(l: Label) { println!("STAMP: {}", l.name); } let l = Label { name: "hello".to_string() }; print(l); println!("My label number is: {}", l.name);
}
Rc và Arc: Chia sẻ quyền sở hữu
Mặc dù hầu hết các giá trị trong Rust đều có một chủ sở hữu duy nhất, nhưng trong một số bài toán cụ thể thì việc luôn đảm bảo điều đó ko hề dễ dàng. Rust cung cấp các kiểu con trỏ đếm tham chiếu Rc và Arc nhiều đối tượng cùng chia sẻ quyền sở hữu khi làm việc với vùng nhớ trên heap.
- Rc (Reference Counting): Sử dụng trong môi trường đơn luồng (single-threaded). Nó cho phép nhiều phần khác nhau của chương trình có thể tham chiếu đến cùng một giá trị mà không cần phải sao chép dữ liệu.
- Arc (Atomic Reference Counting): Giống như Rc nhưng được thiết kế để sử dụng trong môi trường đa luồng (multi-threaded). Đảm bảo an toàn khi chia sẻ dữ liệu các luồng.
use std::rc::Rc; let s: Rc<String> = Rc::new("shirataki".to_string());
let t: Rc<String> = s.clone();
let u: Rc<String> = s.clone(); assert!(s.contains("shira"));
assert_eq!(t.find("taki"), Some(5));
println!("{} are quite chewy, almost bouncy, but lack flavor", u); // Dữ liệu mà Rc/Arc trỏ đến là bất biến (immutable)
s.push_str(" noodles"); // Lỗi khi cố tình sửa: cannot borrow data in an `Rc` as mutable
Ba biến s, t, và u
cùng trở đến một giá trị, số lượng con trỏ được quản lý bởi biến strong ref count
.
Lưu ý: Nếu muốn sửa dữ liệu, cần dùng Rc<RefCell<T>>
, Arc<Mutex<T>>
hoặc Arc<RwLock<T>>
Tài liệu tham khảo
https://www.oreilly.com/library/view/programming-rust-2nd/9781492052586/