1 tháng học Rust - Quyền sở hữu

  • 0

  • 0

  • 0

1 tháng học Rust - Quyền sở hữu

1 tháng học Rust - Quyền sở hữu

Vấn đề

Trong JavaScript hay một số ngôn ngữ khác, chúng ta nghe nhiều đến cụm từ "rò rỉ bộ nhớ" hay "memory leak". Điều này xảy ra khi ứng dụng chiếm bộ nhớ nhiều hơn những gì mà nó cần. Có nhiều nguyên nhân dẫn đến vấn đề này, một trong số đó là bộ nhớ không được giải phóng đúng cách.

Vấn đề của các ngôn ngữ lập trình sở hữu bộ thu gom rác tự động đó chính là làm sao để giải phóng bộ nhớ đã qua sử dụng đúng nơi và đúng lúc. Bởi chính từ trong tư tưởng của nó: "cứ chạy đi rồi rác cứ để tôi quét dọn cho". Nhưng làm sao để biết được một biến là có cần nữa hay không chứ? Đó chính là nhiệm vụ của thuật toán thu gom rác. Mặc dù chúng ta nghe đến nhiều đến thuật toán thu gom rác có đầy vẻ phấn khích, tuy nhiên đến một lúc nào đó, tràn bộ nhớ vẫn là vấn đề phải phải đối mặt một sớm một chiều.

Bộ dọn rác tự động

Tôi tin rằng có nhiều bạn ở đây biết đến cú pháp mallocfree trong ngôn ngữ lập trình C. malloc để cấp phát bộ nhớ cho một biến, còn free để giải phóng chúng. Lý do cho điều này bởi vì trong C không tích hợp sẵn một bộ thu gom rác tự động nên buộc chúng ta phải khai báo và giải phóng bộ nhớ một cách thủ công. Điều này giúp cho lập trình viên kiểm soát được lượng bộ nhớ mà chương trình được phép sử dụng. Tuy nhiên điều gì sẽ xảy ra nếu như họ quên free biến vừa malloc, hay free tận đến 2 lần?… Việc cấp phát thủ công sẽ dẫn đến sai sót mang yếu tố con người.

Trong Rust, chúng ta không có bộ thu gom rác tự động và cũng không có cơ chế cấp phát/giải phóng bộ nhớ thủ công. Vậy làm thế nào để Rust quản lý được bộ nhớ?

Quyền sở hữu

Hãy bắt đầu với một ví dụ đơn giản.

fn main() {
    let a: i32 = 1;
    let b: i32 = a;
    println!("a = {}", a);
    println!("b = {}", b);
}

Chạy chương trình trên, chúng ta thấy có 2 dòng in ra là "a = 1" và "b = 1". Cái này có gì lạ đâu, b = a thì rõ ràng là b cũng bằng 1 rồi còn gì nữa.

Được rồi, hãy sang ví dụ tiếp theo.

fn main() {
    let a = String::from("a");
    let b = a;
    println!("a = {}", a);
    println!("b = {}", b);
}

Dĩ nhiên kết quả là "a = a" và "b = a" rồi! Nhưng thực tế, chương trình trên báo lỗi.

Do cơ chế quyền sở hữu của Rust, giá trị a ban đầu là một String có giá trị "a", sau khi khai báo b = a tức là chúng ta đã nhượng quyền sở hữu String "a" từ a sang b, do đó, a lúc này không nắm giữ giá trị gì nữa và nó được giải phóng khỏi bộ nhớ.

Khoan đã, thế thì ở ví dụ đầu tiên chẳng phải a cũng được gán lại cho b sao? Hết sức đơn giản thôi, i32 là một giá trị được lưu trong stack, việc khai báo một biến bằng với một biến khác trong stack thì Rust sẽ lấy thẳng giá trị đó để gán. Nó giống như là khai báo b = 1.

Ngược lại, String là một giá trị được lưu trên heap. Rust có giải thích rằng, việc sao chép giá trị trong heap là tốn kém và không an toàn. Do đó, kết hợp với cả quyền sở hữu thì giá trị trên heap sẽ được chuyển giao lại cho biến được gán.

Bạn nên đọc tài liệu của Rust vì họ có sơ đồ mô phỏng sử dụng bộ nhớ và lúc nào thì nhượng quyền dữ liệu rất trực quan. Lấy một ví dụ.

let a = Box::new([0; 1_000_000]);
let b = a;

Và đây là cách họ trình bày Rust sử dụng bộ nhớ.

Rust sử dụng bộ nhớ

Tại L1, a sở hữu một giá trị trên heap. Sau đó a được gán cho b ở bước L2 do đó b sở hữu giá trị trên heap của a đồng thời a được giải phóng.

Khác với JavaScript, khi gán một biến với một biến tham chiếu đến kiểu dữ liệu là Object lập tức sinh ra hiện tượng references. Nghĩa là 2 biến đều trỏ đến một địa chỉ nhớ trong heap và cả 2 đều có quyền đọc/ghi dữ liệu có trên đó. Rust không cho phép làm điều đó.

Trong Rust có một số cách để tạo ra dữ liệu lưu trên heap. Có thể kể đến như Vec, String, HashMap hay là Box. Vì thế hãy cẩn trọng khi làm việc với bất kì dữ liệu nào lưu trên heap để tránh mất kiểm soát quyền sở hữu.

Vì vậy, quyền sở hữu trong Rust là một nguyên tắc quản lý heap. Bao gồm:

  • Tất cả dữ liệu trong heap phải được sở hữu bởi chính xác một biến.
  • Rust giải phóng dữ liệu heap khi chủ sở hữu của nó vượt quá phạm vi (scope).
  • Quyền sở hữu có thể được chuyển giao bằng cách gán lại hoặc truyền vào tham số hàm.
  • Dữ liệu heap chỉ có thể được truy cập thông qua chủ sở hữu hiện tại của nó.

Quay trở lại với ví dụ trên, có cách nào để gán giá trị mà không mất quyền sở hữu hay không? Đơn giản là hãy "sao chép" giá trị để tránh việc nhượng quyền bằng phương thức clone.

let a = Box::new([0; 1_000_000]);
let b = a.clone();

clone sao chép dữ liệu sang một ô nhớ khác và gán lại vào b. Do đó phép gán trên không phải là chuyển nhượng quyền sở hữu nữa và a tạm thời chưa được giải phóng.

Việc chuyển nhượng quyền sở hữu không chỉ diễn ra trong phép gán lại giá trị, mà còn trong cả tham số của hàm. Ví dụ.

fn main() {
    let first = String::from("Ferris");
    let full = add_suffix(first);
    println!("{full}, originally {first}"); // first is now used here
}

fn add_suffix(mut name: String) -> String {
    name.push_str(" Jr.");
    name
}

add_suffix là một hàm nhận vào name kiểu String. Nếu nhìn qua, chúng ta có thể dự đoán kết quả là Ferris Jr., originally Ferris. Tuy nhiên trên thực tế chương trình báo lỗi.

Thực tế từ khi gọi hàm add_suffix(first) thì first đã được chuyển nhượng quyền sở hữu cho add_suffix và sau đó nó được giải phóng, do đó dòng println! để in ra giá trị của first là không tồn tại nên bị lỗi.

Bạn đọc có thể thắc mắc tại sao tham số của add_suffix lại viết là mut name: String thì hãy nhớ lại tất cả các biến theo mặc định là bất biến, khi khai báo với mut thì chúng ta mới có thể gán lại giá trị cho nó. Thế nên trong thân hàm mới sử dụng được name.push_str(" Jr."); bởi vì push_str là một hàm gây đột biến (mutation) dữ liệu.

Để giải quyết vấn đề này, chúng ta có thể làm theo cách sau đây.

fn main() {
    let first = String::from("Ferris");
    let (first, full) = add_suffix(first);
    println!("{full}, originally {first}"); // first is now used here
}

fn add_suffix(name: String) -> (String, String) {
    let mut suffix_name = name.clone();
    suffix_name.push_str(" Jr.");
    (name, suffix_name)
}

Thay vì trả về mỗi mình name, add_suffix giờ đây trả về thêm cả namesuffix_name. Ý đồ ở đây là trả về cả tên cũ và tên mới để sau đó gán lại thông qua let (first, full) = add_suffix(first). Cuối cùng, chúng ta vẫn có firstfull tồn tại. Tuy nhiên cách làm này chưa phải là hay nhất, thay vào đó Rust giới thiệu cơ chế References and Borrowing (tham chiếu và vay mượn).

Chúng ta có thể tham chiếu đến các biến có dữ liệu trong heap và "mượn" chúng bằng cách sử dụng &.

fn main() {
    let m1 = String::from("Hello");
    let m2 = String::from("world");
    greet(&m1, &m2); 
    let s = format!("{} {}", m1, m2);
}

fn greet(g1: &String, g2: &String) {
    println!("{} {}!", g1, g2);
}

Mặc dù m1m2 đã được sử dụng cho tham số của greet nhưng nó chỉ là giá trị "vay mượn". Các biến được khai báo &m1, &m2 là các biến mượn, chúng chỉ trỏ đến các biến được mượn mà không thực sự sở hữu giá trị đó.

Rust mượn dữ liệu

Chà, có quá nhiều sự khó hiểu ở trong này, đừng lo lắng, tôi đã phải đọc đi đọc lại rất nhiều lần khái niệm quyền sở hữu này và cũng chưa hiểu thật rõ được chúng. Do đó, tôi khuyên bạn nên xem tài liệu Understanding Ownership để nghe giải thích một cách cặn kẽ hơn.

Cuối cùng, may mắn là Rust có tích hợp bộ kiểm tra mã rất mạnh mẽ. Hãy nhớ lại, trước khi chạy chương trình chúng ta cần build nó thành mã máy, ở trong bước build, Rust sẽ kiểm tra tất cả mọi thứ ví dụ như cú pháp và lỗi liên quan đến quyền sở hữu. Vì thế Rust sẽ nhắc nhở bạn ngay lập tức nếu bạn lỡ làm điều gì đó không đúng.

Tổng kết

Trong bài viết này tôi đã trình bày khái niệm về quyền sở hữu trong Rust, nói ngắn gọn đó là một nguyên tắc quản lý heap do Rust không có bộ thu gom rác tự động. Nhưng đó chưa phải là tất cả, chúng ta sẽ đi sâu vào nguyên lý của quyền sở hữu trong bài viết tiếp theo nhé!

Bạn thấy bài viết này có ích?
  • Không

  • Bình thường

Bình luận
DMCA.com Protection Status