Trong phần này chúng ta sẽ tìm hiểu về con trỏ trong Rust.
Bộ nhớ stack và bộ nhớ heap
Khi một ứng dụng khởi chạy thì ứng dụng này sẽ được hệ điều hành cấp 2MB bộ nhớ stack. Đây là nơi ứng dụng lưu trữ các biến cục bộ và các tham số hàm, chẳng hạn như các biến i32
(chiếm 4 byte trong bộ nhớ stack). Khi ứng dụng gọi một hàm, một phần trong stack sẽ được dùng để lưu trữ lời gọi này. Nhờ có cơ chế này mà vùng nhớ stack biết được hàm nào được gọi trước, hàm nào được gọi sau rồi sau đó trả về theo thứ tự đó cho đúng.
Các biến có kiểu dữ liệu động như string hay mảng thì không được lưu trữ trong bộ nhớ stack. Với những loại giá trị này thì ứng dụng sẽ yêu cầu một vùng nhớ trong một bộ nhớ khác là heap, do đó thông thường bộ nhớ heap có dung lượng dữ liệu lưu trữ lớn hơn bộ nhớ stack.
Thời gian tồn tại của biến
Tất cả các biến trong Rust đều có thời gian tồn tại hữu hạn. Giả sử chúng ta khai báo biến n
và gán giá trị 42
, thì biến này sẽ tồn tại cho đến khi không còn có câu lệnh nào có gọi tới biến này nữa, đây cũng là thời gian tồn tại của biến. Ví dụ chúng ta có đoạn code sau đây:
fn main() {
let n: u32 = 42;
let n2 = n;
life(n);
println!("{}", m); // lỗi: unresolved name `m`.
println!("{}", o); // lỗi: unresolved name `o`.
}
fn life(m: u32) -> u32 {
let o = m;
o
}
Trong đoạn code trên thì biến n
không còn tồn tại cho đến khi kết thúc hàm main()
, tương tự biến m
và o
sẽ chỉ tồn tại khi bắt đầu và kết thúc hàm life()
. Cơ chế này giống hệt với các ngôn ngữ khác.
Và cũng tương tự với các ngôn ngữ khác, các biên được khai báo trong một khối lệnh – tức là trong một cặp dấu ngoặc nhọn {}
thì chỉ tồn tại trong cặp dấu đó mà thôi. Ví dụ;:
{
let PI = 3.14;
}
println!("The value of PI is {}", PI); // lỗi: unresolved name ‘PI’
Ngoài ra trong Rust chúng ta còn có thể chỉ định thời gian tồn tại cho các biến bằng cách ghi các chú thích, một ví dụ về cách chú thích thời gian tồn tại kiểu này là 'static
mà chúng ta đã dùng rất nhiều với kiểu dữ liệu str
, 'static
sẽ khiến cho một biến là biến toàn cục, tức là sẽ tồn tại mãi mãi cho đến khi ứng dụng kết thúc.
Địa chỉ bộ nhớ
Hệ điều hành lưu trữ dữ liệu trong bộ nhớ RAM, bộ nhớ này được quản lý bằng cách phân chia thành các ô nhớ có độ lớn là 1 byte, mỗi ô nhớ sẽ được đánh địa chỉ bắt đầu từ 0 và được biểu diễn bằng số hexa.
Các biến trong Rust khi được khai báo và khởi tạo thì cũng sẽ được cấp một vùng bộ nhớ bằng với độ lớn của kiểu dữ liệu trong RAM và cũng được cấp địa chỉ. Đoạn code sau đây sẽ lấy địa chỉ bộ nhớ của biến:
fn main() {
let a: u8 = 10;
let b: u8 = 10;
let c: u8 = 10;
let d: u32 = 10;
let e: u32 = 10;
let f = &e;
println!("{:?}", &a as *const u8);
println!("{:?}", &b as *const u8);
println!("{:?}", &c as *const u8);
println!("{:?}", &d as *const u32);
println!("{:?}", &e as *const u32);
println!("{:?}", f as *const u32);
}
Chúng ta lấy địa chỉ bộ nhớ của biến bằng cách ghi kí tự & trước tên biến, rồi thêm đoạn as *const <tên_kiểu>.
Chúng ta sẽ tìm hiểu về hàm
*const
sau, đoạn code trên sẽ cho ra output dạng như sau:
0x93723ff9e5 0x93723ff9e6 0x93723ff9e7 0x93723ff9e8 0x93723ff9ec 0x93723ff9ec
Đó là các địa chỉ trong bộ nhớ của từng biến được viết dưới dạng số hexa. Bạn có thể để ý thấy là địa chỉ của tất cả các biến chỉ khác nhau kí tự cuối cùng, lý do là vì các biến được cấp bộ nhớ một cách liên tục – tức là chúng đứng cạnh nhau trong RAM.
Số cuối cùng của 4 biến a
, b
, c
và d
tăng dần theo 1 đơn vị, lý do là vì u8
là số nguyên 8 bit – tức chỉ có 1 byte, và 3 biến a
, b
, c
này chiếm đúng 1 byte trong bộ nhớ.
Ngoài ra chúng ta cũng khai báo biến f
có giá trị là &e
, tức là chứa địa chỉ bộ nhớ của biến e
. Lúc này f
là một biến con trỏ.
Con trỏ
Con trỏ là một biến chứa giá trị là địa chỉ bộ nhớ của một giá trị nào đó. Để lấy giá trị mà con trỏ trỏ tới thì chúng ta dùng toán tử *
. Ví dụ:
fn main() {
let a: u8 = 10;
let b = &a;
println!("a = {}", a);
println!("&a = {:?}", &a as *const u8);
println!("b = {:?}", b as *const u8);
println!("*b = {}", *b);
}
Trong đoạn code trên, chúng ta khai báo biến a
kiểu u8
có giá trị bằng 10
, biến b
là một biến con trỏ trỏ tới địa chỉ của biến a
, khi chúng ta ghi b
bình thường thì chúng ta sẽ có được địa chỉ của biến a
, còn nếu ghi *b
thì có được giá trị mà biến b
đang trỏ tới, tức là giá trị của biến a
.
a = 10 &a = 0x3010f5f74f b = 0x3010f5f74f *b = 10
Việc dùng con trỏ có ích lợi là chúng ta có quyền kiểm soát bộ nhớ một cách linh hoạt hơn, do đó chúng ta có thể viết cách ứng dụng có hiệu suất cao hơn. Tuy nhiên quản lý bộ nhớ là một công việc không dễ dàng nên thông thường ứng dụng sẽ được phát triển lâu hơn.
Chúng ta cũng có thể truyền con trỏ làm tham số cho các hàm, ví dụ:
fn main() {
let q = 42;
println!("{}", square(&q)); // 1764
}
fn square(k: &i32) -> i32 {
return *k * *k;
}
Con trỏ trong Rust giống hoàn toàn với con trỏ trong C++, và ở ngôn ngữ nào đi nữa thì chúng ta cũng nên làm công việc dọn dẹp – tức giải phóng tải nguyên, mặc dù cả Rust và C++ đều có bộ phần dọn dẹp tài nguyên tự động. Tuy nhiên nếu không chủ động giải phóng tài nguyên, đôi khi chúng ta sẽ gặp phải các lỗi ngoại lệ rất khó tìm và sửa.
Rust có một ưu điểm là tự động dò tìm được các lỗi ngoại lệ này trong quá trình biên dịch.