[Rust Book] 4.1. Ownership là gì

Ownership là tính năng độc đáo nhất của Rust, nó cho phép Rust đảm bảo an toàn cho bộ nhớ mà không cần garbage collector. Do đó, hiểu về cách hoạt động của ownership trong Rust rất quan trọng. Trong chương này, chúng ta sẽ nói về ownership cũng như nhiều tính năng khác: borrowing, slice và cách Rust loại dữ liệu ra khỏi bộ nhớ.

Ownership là gì?

Tính năng trung tâm của Rust là ownership. Mặc dù bản thân tính năng khá đơn giản để giải thích, nhưng nó có ảnh hưởng rất sâu đến phần còn lại của ngôn ngữ.

Tất cả các chương trình phải quản lý cách chúng sử dụng bộ nhớ máy tính khi chạy. Một vài ngôn ngữ có garbage collection liên tục tìm những phần không dùng bộ nhớ nữa trong khi chương trình chạy; một vài ngôn ngữ khác thì lập trình viên phải tự chỉ ra và làm trống bộ nhớ. Rust sử dụng cách tiếp cận thứ ba: bộ nhớ được quản lý thông qua một hệ thống ownership với một bộ quy tắc cho trình biên dịch kiểm tra khi biên dịch. Không có tính năng nào ownership làm chậm chương trình của bạn khi chạy cả.

Bởi vì ownership là một khái niệm mới đối với nhiều lập trình viên, nên sẽ mất một chút thời gian để làm quen với nó. Tin tốt là khi bạn có càng nhiều kinh nghiệm với Rust và quy tắc của hệ thống ownership hơn, bạn sẽ càng dễ phát triển code an toàn và hiệu quả hơn.

Khi bạn hiểu ownership, bạn sẽ có một nền tảng vững chắc để hiểu những tính năng làm cho Rust trở nên độc đáo. Trong chương này, bạn sẽ học về ownership bằng việc làm các ví dụ tập trung vào một kiểu cấu trúc rất phổ biến: string.

Stack và Heap

Trong nhiều ngôn ngữ lập trình, bạn không thường xuyên phải nghĩ về stack và heap. Nhưng trong một ngôn ngữ lập trình hệ thống như Rust, việc một giá trị nằm trên stack hay heap sẽ tác động đến cách ngôn ngữ hoạt động và lý do cho những quyết định của bạn. Những phần của ownership sẽ được mô tả trong mối quan hệ với stack và heap ở phần sau trong chương này, ngắn gọn thì có thể giải thích như sau.

Cả stack và heap đều là một phần của bộ nhớ cho code sử dụng khi runtime, nhưng chúng được cấu tạo theo những cách khác nhau. Stack lưu giá trị theo thứ tự nó nhận được và loại bỏ giá trị ở chiều ngược lại. Nó thường được nhắc đến là vào sau ra trước. Hãy tưởng tượng nó như một chồng đĩa: khi bạn thêm đĩa, bạn đặt chúng vào phía trên chồng đĩa, và khi bạn cần một chiếc đĩa, bạn sẽ lấy nó ra từ phía trên. Bạn không thể thêm và bớt đĩa từ phần giữa của chồng đĩa. Việc thêm dữ liệu được gọi là pushing onto the stack, và việc xóa dữ liệu được gọi là popping off the stack.

Tất cả dữ liệu lưu trên stack phải có kích thước cố định được biết trước. Dữ liệu với kích thước không biết trước khi biên dịch hoặc kích thước có thể thay đổi phải được lưu trên heap. Heap ít có tổ chức hơn: khi bạn đưa dữ liệu vào heap, bạn yêu cầu một không gian nhất định. Bộ cấp phát bộ nhớ tìm một khoảng trống trong heap đủ rộng, đánh dấu nó là đang được sử dụng và trả về một con trỏ chính là địa chỉ của vùng đó. Tiến trình này được gọi là allocating on the heap và đôi khi được viết tắt là allocating. Việc đẩy giá trị vào stack không được coi là allocating. Bởi vì con trỏ là một giá trị đã biết, có kích thước cố định, bạn có thể lưu con trỏ trên stack nhưng khi bạn muốn dữ liệu thật sự, bạn phải đi theo con trỏ.

Tưởng tượng như việc tìm chỗ ngồi ở một nhà hàng. Khi bạn vào nhà hàng, bạn nói số người trong nhóm của bạn, nhân viên sẽ tìm một bàn trống đủ chỗ cho mọi và dẫn bạn tới đó. Nếu ai đó trong nhóm đến muộn, họ có thể hỏi chỗ ngồi của bạn ở đâu để tìm bạn.

Đẩy vào stack nhanh hơn so với việc cấp phát trên bộ nhớ heap bởi vì bộ cấp phát không bao giờ phải tìm một nơi để lưu dữ liệu mới; vị trí đó luôn là đỉnh của stack. Cấp phát không gian trên heap yêu cầu nhiều công việc hơn, bởi vì bộ cấp phát trước tiên phải tìm một khoảng không gian đủ lớn để lưu dữ liệu, sau đó thực hiện bookkeeping để chuẩn bị cho lần cấp phát kế tiếp.

Truy cập dữ liệu trên heap chậm hơn với trên stack bởi vì bạn phải theo con trỏ để đến được đó. Các bộ xử lý hiện đại xử lý nhanh hơn nếu chúng nhảy qua lại trong bộ nhớ ít hơn. Tương tự, ví dụ ở một nhà hàng lấy yêu cầu từ nhiều bàn. Cách hiệu quả nhất là lấy tất cả yêu cầu ở một bàn một lần trước khi chuyển sang bàn tiếp theo. Việc lấy yêu cầu từ bàn A, rồi yêu cầu ở bàn B, rồi lại ở bàn A, rồi lại ở bàn B sẽ chậm hơn rất nhiều. Cùng vì lẽ đó, một bộ xử lý có thể làm việc của nó tốt hơn nếu nó làm việc với các dữ liệu gần nhau (như trên stack) thay vì xa nhau (như trên heap). Việc phân bổ một lượng lớn không gian trên heap cũng có thể mất thời gian.

Khi bạn gọi một hàm, những giá trị đã truyền vào hàm (có thể bao gồm cả con trỏ đến dữ liệu trên heap) và những biến cục bộ của hàm được đẩy vào stack. Khi hàm kết thúc, những giá trị này sẽ bị loại khỏi stack.

Luôn theo dõi những phần code nào đang sử dụng dữ liệu gì trên heap, tối thiểu lượng dữ liệu lặp lại trên heap và dọn dẹp dữ liệu không dùng trên heap, do đó bạn sẽ không dùng hết không gian là tất cả những vấn đề mà ownership hướng tới. Một khi bạn hiểu về ownership, bạn sẽ không cần nghĩ về stack và heap thường xuyên nữa, mà biết về việc quản lý dữ liệu heap là lý do ownership tồn tại có thể giúp giải thích tại sao nó lại hoạt động như vậy.

Những quy tắc của Ownership

Đầu tiên, hãy cùng nhìn qua những quy tắc của ownership. Các bạn hãy nhớ những quy tắc này vì chúng ta sẽ làm qua những ví dụ minh họa chúng:

  • Mỗi giá trị trong Rust có một biến gọi là owner của nó.
  • Chỉ có thể có một owner tại một thời điểm.
  • Khi owner ra khỏi scope (phạm vi) của nó, giá trị sẽ được giải phóng.

Phạm vi biến

Qua một ví dụ của một chương trình Rust ở Chương 2, bây giờ, chúng ta đã biết cú pháp cơ bản, chúng ta sẽ không viết tất cả các dòng fn main() { trong ví dụ nữa, nên bạn sẽ phải tự đặt những ví dụ sau vào bên trong hàm main. Cũng nhờ đó, những ví dụ của chúng ta sẽ ngắn gọn hơn một chút, cho phép chúng ta tập trung vào những chi tiết thực sự hơn là những đoạn code soạn sẵn.

Ví dụ đầu tiên, chúng ta hãy xét tới scope (phạm vi) của một số biến. Scope là một khoảng trong một chương trình mà item vẫn còn hợp lệ. Giả sử chúng ta có một biến như sau:

let s = "hello";

Biến s tham chiếu tới một chuỗi kí tự mà giá trị của chuỗi được gán cứng vào một văn bản trong chương trình. Biến hợp lệ từ điểm nó được khai báo cho tới cuối của scope hiện tại. Listing 4-1 đã ghi chú nơi mà biến s hợp lệ.

    {                       // s is not valid here, it’s not yet declared
        let s = "hello";    // s is valid from this point forward

        // do stuff with s
}                           // this scope is now over, and s is no longer valid

Listing 4-1: Biến và phạm vi hợp lệ

Nói cách khác, ở đây có hai điểm quan trọng trong thời gian:

  • Khi s ở trong scope, nó hợp lệ.
  • Nó vẫn hợp lệ cho tới khi nó out of scope.

Cho tới điểm này, mối quan hệ giữa scope và việc khi nào biến hợp lệ tương tự như những ngôn ngữ lập trình khác. Bây giờ, dựa trên những hiểu biết này, chúng ta sẽ tìm hiểu tiếp thông qua kiểu String.

Kiểu String

Để minh họa những quy tắc của ownership, chúng ta cần một kiểu dữ liệu phức tạp hơn kiểu mà chúng ta đã đề cập đến ở phần “Data Types” của Chương 3. Những kiểu đã đề cập trước đó đều được lưu trữ trên stack và bị xóa khỏi stack khi phạm vi của chúng kết thúc, nhưng chúng ta muốn nhìn vào dữ liệu được lưu trên heap và khám phá bằng cách nào mà Rust biết khi nào cần dọn dẹp dữ liệu.

Chúng ta sẽ sử dụng String như ví dụ ở đây và tập trung vào phần liên quan tới ownership của String. Những khía cạnh này cũng áp dụng cho những kiểu dữ liệu phức tạp khác, bất kể chúng được cung cấp bởi standard library hay do bạn tạo ra. Chúng ta sẽ thảo luận sâu hơn về String trong Chương 8.

Chúng ta đã nhìn thấy những chuỗi kí tự (string literal) có giá trị được gán cứng trong chương trình. Kiểu chuỗi như này khá tiện lợi nhưng chúng không phải luôn phù hợp trong mọi chương trình. Một lí do là chúng immutable. Lí do khác là không phải giá trị chuỗi nào cũng được biết trước khi viết code: ví dụ nếu chúng ta muôn lấy input của người dùng và lưu nó thì sao? Trong tình huống này, Rust có một kiểu chuỗi thứ hai, String. Kiểu dữ liệu này được phân bổ trên heap, như thế nó có thể lưu trữ một khối lượng văn bản không biết trước ở thời điểm biên dịch. Bạn có thể tạo một String từ mỗi chuỗi kí tự bằng hàm from, như sau:

let s = String::from("hello");

Hai dấu hai chấm (::) là một toán tử cho phép chúng ta gọi hàm (namespace) from của kiểu String thay vì dùng một cái tên nào đó như là string_from. Chúng ta sẽ thảo luận về cú pháp này nhiều hơn trong phần “Method Syntax” của Chương 5 và khi chúng ta nói về namespacing với module ở phần “Paths for Referring to an Item in the Module Tree” trong Chương 7.

Kiểu chuỗi này cũng có thể thay đổi giá trị (mutated):

    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{}", s); // This will print `hello, world!`

Vậy điểm khác biệt ở đây là gì? Tại sao String mutable trong khi string literal thì không? Khác biệt chính là cách hai kiểu này tương tác với bộ nhớ.

Memory và Allocation

Trong trường hợp string literal, chúng ta đã biết nội dung tại thời điểm biên dịch nên văn bản được gán trực tiếp trong file thực thi. Đó là lý do vì sao mà string literal lại nhanh và hiệu quả. Nhưng đó bởi vì string literal là immutable. Không may là chúng ta không thể đặt một blob memory vào trong file nhị phân cho mỗi đoạn văn bản mà không biết trước kích thước lúc biên dịch và những đoạn văn bản mà kích thước có thể thay đổi trong lúc chạy.

Với kiểu String, để hỗ trợ một đoạn văn bản có thể thay đổi, chúng ta cần phân bổ một lượng bộ nhớ heap, không biết trước tại thời điểm biên dịch, để lưu trữ nội dung. Điều này có nghĩa là:

  • Bộ nhớ phải được yêu cầu từ bộ cấp phát bộ nhớ (memory allocator) lúc runtime.
  • Chúng ta cần có cách để trả lại bộ nhớ này cho bộ cấp phát khi chúng ta xong việc với String của chúng ta.

Điều đầu tiên được thực hiện bởi chúng ta: khi chúng ta gọi String::from, nó yêu cầu lượng bộ nhớ nó cần. Điều này khá phổ biến trong các ngôn ngữ lập trình.

Tuy nhiên, điều thứ hai thừ khác. Trong những ngôn ngữ có garbage collector (GC), GC sẽ theo dõi và dọn dẹp vùng nhớ không còn được sử dụng, và chúng ta không cần nghĩ về nó. Không có GC, xác định khi nào vùng nhớ không còn được gọi nữa và gọi code để trả lại nó một cách rõ ràng là việc của chúng ta, giống như việc chúng ta đã yêu cầu nó. Trong lịch sử để làm chính xác việc này từng là một vấn đề khó của lập trình. Nếu chúng ta quên, chúng ta sẽ lãng phí bộ nhớ. Nếu chúng ta trả lại quá sớm, chúng ta sẽ có biến không hợp lệ. Nếu chúng ta làm nó hai lần, đó cũng là lỗi. Chúng ta cần ghép chính xác một allocate với một free.

Rust đi theo một hướng khác: bộ nhớ được tự động trả lại một khi mà biến sở hữu nó ra khỏi scope. Đây là một ví dụ về scope từ Listing 4-1 sử dụng String thay vì string literal.

    {
    let s = String::from("hello");  // s is valid from this point forward

    // do stuff with s
}                                   // this scope is now over, and s is no
                                    // longer valid

Có một điểm tự nhiên nơi chúng ta có thể trả lại vùng nhớ String của chúng ta cho bộ cấp phát: khi s ra khỏi scope. Khi một biến ra khỏi scope, Rust gọi một hàm đặc biệt cho chúng ta. Hàm này gọi là drop, và nó là nơi mà tác giả của String có thể đặt code để trả lại bộ nhớ. Rust gọi drop tự động tại dấu đóng ngoặc nhọn.

Lưu ý: Trong C++, mô hình giải phóng tài nguyên vào cuối vòng đời của một item đôi khi được gọi là Resource Acquisition Is Initialization (RAII). Hàm drop trong Rust sẽ quen thuộc hơn với bạn nếu bạn từng dùng mô hình RAII.

Mô hình này có ảnh hưởng sâu sắc tới cách viết code Rust. Bây giờ, nó có thể trông đơn giản, nhưng code có thể có những hành vi không mong muốn trong những trường hợp phức tạp khi chúng ta muốn có nhiều biến sử dụng dữ liệu chúng ta đã cấp phát trên heap. Bây giờ, hãy cùng khám phá một vài trường hợp đó.

Những cách tương tác biến và dữ liệu: Move

Nhiều biến có thể tương tác với cùng dữ liệu theo những cách khác nhau trong Rust. Chúng ta hãy cùng nhìn vào một ví dụ sử dụng một biến integer trong Listing 4-2.

    let x = 5;
    let y = x;

Listing 4-2: Gán giá trị integer của biến x cho y

Chúng ta có lẽ đoán ra được đoạn code đang làm gì: “gán giá trị 5 cho x; sau đó tạo một bản sao của giá trị của x và gán nó cho y”. Giờ chúng ta có hai biến, xy, và đều bằng 5. Đây thực sự là những gì đang diễn ra, bởi vì những số integer là những giá trị đơn giản với kích thước cố định, đã được biết trước và hai giá trị 5 này được đẩy vào stack.

Giờ hãy cùng nhìn vào phiên bản String:

    let s1 = String::from("hello");
    let s2 = s1;

Trông khá giống với đoạn code trước, chúng ta có thể giả sử rằng cách nó hoạt động cũng tương tự: dòng thứ hai sẽ tạo một bản sao của giá trị trong s1 và gán nó cho s2. Nhưng đây không phải là điều sẽ diễn ra.

Hãy nhìn qua Hình 4-1 để xem điều gì đang diễn ra với String. Một String được tạo bởi ba phần, ở phía bên trái: một con trỏ tới vùng nhớ chứa nội dung của chuỗi, độ dài và dung lượng. Nhóm dữ liệu này được lưu trữ trên stack. Ở phía bên phải là vùng nhớ trên chứa nội dung trên heap.

String in memory

Hình 4-1: Biểu diễn trong bộ nhớ của một String chứa giá trị "hello" gán cho s1

len là kích thước bộ nhớ, tính bằng byte, mà nội dung của String đang sử dụng. capacity là tổng lượng bộ nhớ, tính bằng byte, mà String đã nhận từ bộ cấp phát. lencapacity là khác nhau, tuy nhiên bây giờ chúng ta có thể tạm thời bỏ qua chúng.

Khi chúng ta gán s1 cho s2, dữ liệu String được sao chép, có nghĩa là chúng ta sao chép ptr, lencapacity trên stack. Chúng ta không sao chép dữ liệu trên heap mà con trỏ chỉ tới. Nói cách khác, dữ liệu được biểu diễn trong bộ nhớ như Hình 4-2.

s1 and s2 pointing to the same value

Hình 4-2: Biểu diễn trong bộ nhớ của biến s2 chứa bản sao của con trỏ, độ dài và dung lượng của s1

Hình minh họa không giống hình 4-3, minh họa bộ nhớ sẽ thế nào nếu thay vào đó Rust sao chép dữ liệu heap. Nếu Rust làm như vậy, phép toán s2 = s1 có thể rất tốn hiệu năng nếu dữ liệu trên heap lớn.

s1 and s2 to two places

Hình 4-3: Khả năng khác của s2 = s1 nếu Rust sao chép dữ liệu heap

Trước đó, chúng ta đã nói rằng khi một biến ra khỏi scope, Rust tự động gọi hàm drop và dọn dẹp bộ nhớ heap của biến đó. Nhưng Hình 4-2 lại cho ta thấy cả hai con trỏ đều trỏ tới cùng một địa chỉ. Đây là vấn đề: khi s2s1 ra khỏi scope, cả hai biến đó sẽ cố gắng giải phóng cùng một đoạn bộ nhớ. Vấn đề này được biết đến là lỗi double free và là một trong những lỗi an toàn bộ nhớ mà chúng ta đã đề cập trước đó. Giải phóng bộ nhớ hai lần có thể dẫn đến lỗi bộ nhớ, và dẫn tới các lỗ hổng bảo mật.

Để đảm bảo an toàn bộ nhớ, có một chi tiết cho việc điều gì xảy ra trong tình huống này trong Rust. Thay vì cố gắng sao chép vùng bộ nhớ đã được cấp phát, Rust coi s1 không còn hợp lệ nữa, do đó, Rust không cần giải phóng gì cả khi s1 ra khỏi scope. Thử xem điều gì xảy ra khi bạn cố sử dụng s1 sau khi s2 được tạo; nó không thể sử dụng được:

    let s1 = String::from("hello");
    let s2 = s1;

    println!("{}, world!", s1);

Bạn gặp lỗi như trên bởi vì Rust ngăn bạn khỏi việc sử dụng tham chiếu không hợp lệ:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 | 
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` due to previous error

Nếu bạn đã nghe về thuật ngữ shallow copy (sao chép cạn) và deep copy (sao chép sâu) trong khi đang làm việc với những ngôn ngữ khác, khái niệm của việc sao chép con trỏ, độ dài và dung lượng mà không sao chép dữ liệu có thể nghe giống như việc tạo một bản sao cạn. Nhưng bởi vì Rust vô hiệu biến đầu tiên, thay vì gọi là sao chép cạn, nó được biết đến là move (di chuyển). Trong ví dụ này, chúng ta sẽ nói rằng s1 đã được move vào s2. Những gì thực sự xảy ra được thể hiện trong Hình 4-4.

s1 moved to s2

Hình 4-4: Biển diễn bộ nhớ sau khi s1 bị vô hiệu

Vấn đề của chúng ta được giải quyết! Với chỉ s2 hợp lệ, khi nó ra khỏi scope, chỉ một mình nó giải phóng bộ nhớ, và thế là chúng ta xong việc.

Ngoài ra, có một lựa chọn trong thiết kế được ngụ ý bởi việc này: Rust sẽ không bao giờ tự động tạo những bản sao chép “sâu” của dữ liệu của bạn. Do đó, bất kì việc sao chép tự động nào cũng sẽ nhẹ nhàng hơn về mặt hiệu suất runtime.

Những cách tương tác của biến và dữ liệu: Nhân bản (Clone)

Nếu chúng ta muốn deeply copy dữ liệu heap của String, không chỉ dữ liệu stack, chúng ta có thể sử dụng một method (phương thức) phổ biến gọi là clone. Chúng ta sẽ bàn về cú pháp của method trong Chương 5, nhưng bởi vì các method là một tính năng phổ biến trong các ngôn ngữ lập trình, chúng ta có thể xem qua trước.

Đây là một ví dụ của method clone:

    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {}, s2 = {}", s1, s2);

Đoạn code hoạt động tốt và rõ ràng tạo ra hành vi như được hiển thị trong Hình 4-3, khi dữ liệu heap được sao chép.

Khi bạn thây một lệnh gọi clone, bạn biết rằng một vài đoạn code nào đó đang được thực thi và code đó có thể khá tốn tài nguyên. Nó như là một chỉ báo trực quan rằng có điều gì đó khác thường đang diễn ra.

Dữ liệu chỉ trên Stack (Stack-Only Data): Sao chép (Copy)

Có một trường hợp khác mà chúng ta vẫn chưa nói đến. Đoạn code này dùng integer như trong Listing 4-2 và vẫn hợp lệ:

    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y);

Nhưng đoạn code này dường như mâu thuẫn với những gì chúng ta vừa học: chúng ta không gọi clone, nhưng x vẫn hợp lệ và không bị move vào y.

Lý do là những kiểu như integer có kích thước đã biết trước tại thời điểm biên dịch và được lưu trữ hoàn toàn trên stack, nên việc sao chép giá trị thực tế được làm nhanh chóng. Điều đó có nghĩa rằng không có lý do gì để khiến x không hợp lệ sau khi chúng ta tạo biến y. Nói cách khác, ở đây không có gì khác giữa deep copy và shallow copy, nên việc gọi clone không làm gì khác biệt so với shallow copy bình thường và chúng ta có thể kệ nó như thế.

Rust có một annotation gọi là Copy trait, chúng ta có thể đặt nó trên những kiểu được lưu trữ trên stack như integer (chúng ta sẽ nói nhiều hơn về trait trong Chương 10). Nếu một kiểu thiết lập Copy trait, một biến cũ hơn sẽ vẫn khả dụng sau khi bị gán đi. Rust không cho phép chúng ta annotate một kiểu với Copy trait nếu kiểu của nó, hoặc bất kì phần nào của nó, đã thiết lập Drop trail. Nếu kiểu cần điều gì đó đặc biệt xảy ra khi giá trị ra khỏi scope và chúng ta thêm Copy annotation vào kiểu đó, một lỗi biên dịch sẽ được trả ra. Để học về cách thêm Copy annotation vào kiểu của bạn để thiết lập trail, bạn có thể xem qua “Derivable Traits” trong Phụ lục C.

Vậy những kiểu nào thiết lập Copy trail? Bạn có thể xem tài liệu về kiểu cho chắc chắn, nhưng có một quy tắc chung, bất kỳ nhóm nào của những giá trị vô hướng đơn giản đều có thể thiết lập Copy, và không có kiểu nào mà yêu cầu cấp phát (allocation) hoặc là một dạng của resource có thể thiết lập Copy. Đây là một vài kiểu có thể thiết lập Copy:

  • Tất cả các kiểu số nguyên, ví dụ u32.
  • Kiểu Boolean, bool, với giá trị truefalse.
  • Tất cả các kiểu dấu phẩy động, ví dụ f64.
  • Kiểu ký tự, char.
  • Tuples, nếu chúng chỉ chứa những kiểu có thể thiết lập Copy. Ví dụ (i32, i32) thiết lập Copy, nhưng (i32, String) thì không.

Ownership và Hàm

Ý nghĩa cho việc truyền một giá trị tới một hàm tương tự việc gán một giá trị vào một biến. Việc truyền một biến vào hàm sẽ có thể là move hoặc copy, giống như phép gán. Listing 4-3 có một ví dụ với vài chú thích thể hiện những biến đã đi vào đâu và ra khỏi phạm vi ở đâu.

Filename: src/main.rs

fn main() {
    let s = String::from("hello");  // s comes into scope
  
    takes_ownership(s);             // s's value moves into the function...
    // ... and so is no longer valid here
  
    let x = 5;                      // x comes into scope

    makes_copy(x);                  // x would move into the function,
    // but i32 is Copy, so it's okay to still
    // use x afterward

} // Here, x goes out of scope, then s. But because s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

Listing 4-3: Hàm với ownership và chú thích phạm vi

Nếu chúng ta cố sử dụng s sau khi gọi takes_ownership, Rust sẽ ném ra một lỗi biên dịch. Những phép kiểm tra tĩnh này sẽ bảo vệ chúng ta khỏi những sai lầm. Thử thêm code vào main sử dụng sx để xem bạn có thể dùng chúng ở đâu và những quy tắc ownership ngăn bạn làm việc đó ở đâu.

Trả về giá trị và Phạm vi

Việc trả lại giá trị cũng có thể chuyển giao ownership. Listing 4-4 là một ví dụ với chú thích tương tự với Listing 4-3.

Filename: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1

    let s2 = String::from("hello");     // s2 comes into scope

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function
}

// This function takes a String and returns one
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope

    a_string  // a_string is returned and moves out to the calling function
}

Listing 4-4: Di chuyển ownership của những giá trị trả về

Ownership của một biến lúc nào cũng theo cùng hình mẫu: việc gán một giá trị cho một biến khác sẽ move nó. Khi một biến chứa dữ liệu trên heap ra khỏi scope, giá trị sẽ được xóa bởi drop trừ khi dữ liệu đã được move và được sở hữu bởi biến khác.

Việc lấy ownership và sau đó trả về ownership với hàm có chút dài dòng. Nếu chúng ta muốn một hàm dùng một giá trị nhưng không lấy đi ownership thì sao? Sẽ rất phiền phức nếu mọi thứ chúng ta truyền đi cũng cần được truyền lại nếu chúng ta muốn sử dụng lại nó, thêm cả bất kỳ dữ liệu nào từ thân hàm mà chúng ta muốn trả về nữa.

Chúng ta có thể trả về nhiều giá trị bằng cách sử dụng tuple, như được thể hiện trong Listing 4-5.

Filename: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}

Listing 4-5: Trả về ownership của tham số

Nhưng việc này quá nhiều thủ tục và việc cần làm cho một khái niệm đơn giản. May mắn thay, Rust có một tính năng cho khái niệm này, gọi là references (tham chiếu).

Source

https://doc.rust-lang.org/stable/book/ch04-01-what-is-ownership.html

Bài liên quan

comments powered by Disqus