Xử lý Con trỏ Thông minh Như Tham chiếu Thông thường với Deref

Việc triển khai trait Deref cho phép bạn tùy chỉnh hành vi của toán tử giải tham chiếu * (không nên nhầm lẫn với toán tử nhân hoặc toán tử glob). Bằng cách triển khai Deref theo cách mà một con trỏ thông minh có thể được xử lý như một tham chiếu thông thường, bạn có thể viết mã hoạt động trên tham chiếu và sử dụng mã đó với con trỏ thông minh.

Đầu tiên, hãy xem cách toán tử giải tham chiếu hoạt động với tham chiếu thông thường. Sau đó, chúng ta sẽ thử định nghĩa một kiểu tùy chỉnh hoạt động giống như Box<T>, và xem tại sao toán tử giải tham chiếu không hoạt động như một tham chiếu trên kiểu mới định nghĩa của chúng ta. Chúng ta sẽ khám phá cách triển khai trait Deref khiến con trỏ thông minh có thể hoạt động tương tự như tham chiếu. Sau đó, chúng ta sẽ xem tính năng chuyển đổi giải tham chiếu (deref coercion) của Rust và cách nó cho phép chúng ta làm việc với cả tham chiếu hoặc con trỏ thông minh.

Lưu ý: Có một sự khác biệt lớn giữa kiểu MyBox<T> mà chúng ta sắp xây dựng và Box<T> thực tế: phiên bản của chúng ta sẽ không lưu trữ dữ liệu trên heap. Chúng ta đang tập trung ví dụ này vào Deref, vì vậy nơi dữ liệu thực sự được lưu trữ ít quan trọng hơn hành vi giống con trỏ.

Theo dõi Con trỏ đến Giá trị

Một tham chiếu thông thường là một loại con trỏ, và một cách để nghĩ về con trỏ là như một mũi tên đến một giá trị được lưu trữ ở đâu đó. Trong Listing 15-6, chúng ta tạo một tham chiếu đến một giá trị i32 và sau đó sử dụng toán tử giải tham chiếu để theo dõi tham chiếu đến giá trị.

fn main() {
    let x = 5;
    let y = &x;

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Biến x chứa một giá trị i325. Chúng ta đặt y bằng một tham chiếu đến x. Chúng ta có thể khẳng định rằng x bằng 5. Tuy nhiên, nếu chúng ta muốn khẳng định về giá trị trong y, chúng ta phải sử dụng *y để theo dõi tham chiếu đến giá trị mà nó đang trỏ tới (do đó giải tham chiếu) để trình biên dịch có thể so sánh giá trị thực. Một khi chúng ta giải tham chiếu y, chúng ta có quyền truy cập vào giá trị số nguyên mà y đang trỏ tới mà chúng ta có thể so sánh với 5.

Nếu chúng ta cố gắng viết assert_eq!(5, y); thay vào đó, chúng ta sẽ nhận được lỗi biên dịch này:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0277]: can't compare `{integer}` with `&{integer}`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
  |
  = help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0277`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

So sánh một số và một tham chiếu đến một số không được phép vì chúng là các kiểu khác nhau. Chúng ta phải sử dụng toán tử giải tham chiếu để theo dõi tham chiếu đến giá trị mà nó đang trỏ tới.

Sử dụng Box<T> Như một Tham chiếu

Chúng ta có thể viết lại mã trong Listing 15-6 để sử dụng Box<T> thay vì một tham chiếu; toán tử giải tham chiếu được sử dụng trên Box<T> trong Listing 15-7 hoạt động theo cùng một cách như toán tử giải tham chiếu được sử dụng trên tham chiếu trong Listing 15-6:

fn main() {
    let x = 5;
    let y = Box::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Sự khác biệt chính giữa Listing 15-7 và Listing 15-6 là ở đây chúng ta đặt y là một thực thể của một box trỏ đến một giá trị sao chép của x thay vì một tham chiếu trỏ đến giá trị của x. Trong khẳng định cuối cùng, chúng ta có thể sử dụng toán tử giải tham chiếu để theo dõi con trỏ của box theo cách tương tự như khi y là một tham chiếu. Tiếp theo, chúng ta sẽ khám phá điều gì đặc biệt về Box<T> cho phép chúng ta sử dụng toán tử giải tham chiếu bằng cách định nghĩa kiểu của riêng chúng ta.

Định nghĩa Con trỏ Thông minh Riêng của Chúng ta

Hãy xây dựng một con trỏ thông minh tương tự như kiểu Box<T> được cung cấp bởi thư viện chuẩn để trải nghiệm cách con trỏ thông minh hoạt động khác với tham chiếu theo mặc định. Sau đó, chúng ta sẽ xem cách thêm khả năng sử dụng toán tử giải tham chiếu.

Kiểu Box<T> cuối cùng được định nghĩa như một tuple struct với một phần tử, vì vậy Listing 15-8 định nghĩa một kiểu MyBox<T> theo cách tương tự. Chúng ta cũng sẽ định nghĩa một hàm new để phù hợp với hàm new được định nghĩa trên Box<T>.

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {}

Chúng ta định nghĩa một struct có tên MyBox và khai báo một tham số generic T vì chúng ta muốn kiểu của chúng ta giữ các giá trị của bất kỳ kiểu nào. Kiểu MyBox là một tuple struct với một phần tử kiểu T. Hàm MyBox::new nhận một tham số kiểu T và trả về một thực thể MyBox chứa giá trị được truyền vào.

Hãy thử thêm hàm main trong Listing 15-7 vào Listing 15-8 và thay đổi nó để sử dụng kiểu MyBox<T> mà chúng ta đã định nghĩa thay vì Box<T>. Mã trong Listing 15-9 sẽ không biên dịch vì Rust không biết cách giải tham chiếu MyBox.

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Đây là lỗi biên dịch kết quả:

$ cargo run
   Compiling deref-example v0.1.0 (file:///projects/deref-example)
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
  --> src/main.rs:14:19
   |
14 |     assert_eq!(5, *y);
   |                   ^^

For more information about this error, try `rustc --explain E0614`.
error: could not compile `deref-example` (bin "deref-example") due to 1 previous error

Kiểu MyBox<T> của chúng ta không thể được giải tham chiếu vì chúng ta chưa triển khai khả năng đó trên kiểu của chúng ta. Để kích hoạt giải tham chiếu với toán tử *, chúng ta triển khai trait Deref.

Triển khai Trait Deref

Như đã thảo luận trong "Triển khai một Trait trên một Kiểu" trong Chương 10, để triển khai một trait, chúng ta cần cung cấp các triển khai cho các phương thức yêu cầu của trait. Trait Deref, được cung cấp bởi thư viện chuẩn, yêu cầu chúng ta triển khai một phương thức có tên deref mượn self và trả về một tham chiếu đến dữ liệu bên trong. Listing 15-10 chứa một triển khai của Deref để thêm vào định nghĩa của MyBox<T>.

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn main() {
    let x = 5;
    let y = MyBox::new(x);

    assert_eq!(5, x);
    assert_eq!(5, *y);
}

Cú pháp type Target = T; định nghĩa một kiểu liên kết cho trait Deref để sử dụng. Kiểu liên kết là một cách khác để khai báo một tham số generic, nhưng bạn không cần phải lo lắng về chúng bây giờ; chúng ta sẽ đề cập đến chúng chi tiết hơn trong Chương 20.

Chúng ta điền vào thân của phương thức deref với &self.0 để deref trả về một tham chiếu đến giá trị mà chúng ta muốn truy cập với toán tử *; nhớ lại từ "Sử dụng Tuple Structs Không Có Trường Có Tên để Tạo Các Kiểu Khác nhau" trong Chương 5 rằng .0 truy cập giá trị đầu tiên trong một tuple struct. Hàm main trong Listing 15-9 gọi * trên giá trị MyBox<T> bây giờ biên dịch được, và các khẳng định đều thành công!

Không có trait Deref, trình biên dịch chỉ có thể giải tham chiếu tham chiếu &. Phương thức deref cung cấp cho trình biên dịch khả năng lấy một giá trị của bất kỳ kiểu nào triển khai Deref và gọi phương thức deref để lấy tham chiếu & mà nó biết cách giải tham chiếu.

Khi chúng ta nhập *y trong Listing 15-9, đằng sau hậu trường Rust thực sự chạy mã này:

*(y.deref())

Rust thay thế toán tử * bằng một lệnh gọi đến phương thức deref và sau đó là một giải tham chiếu thông thường để chúng ta không phải suy nghĩ về việc liệu chúng ta có cần gọi phương thức deref hay không. Tính năng Rust này cho phép chúng ta viết mã hoạt động giống hệt nhau dù chúng ta có một tham chiếu thông thường hay một kiểu triển khai Deref.

Lý do phương thức deref trả về một tham chiếu đến một giá trị, và giải tham chiếu thông thường bên ngoài dấu ngoặc đơn trong *(y.deref()) vẫn cần thiết, liên quan đến hệ thống sở hữu. Nếu phương thức deref trả về giá trị trực tiếp thay vì một tham chiếu đến giá trị, giá trị sẽ bị di chuyển ra khỏi self. Chúng ta không muốn lấy quyền sở hữu của giá trị bên trong MyBox<T> trong trường hợp này hoặc trong hầu hết các trường hợp khi chúng ta sử dụng toán tử giải tham chiếu.

Lưu ý rằng toán tử * được thay thế bằng một lệnh gọi đến phương thức deref và sau đó là một lệnh gọi đến toán tử * chỉ một lần, mỗi lần chúng ta sử dụng * trong mã của chúng ta. Vì sự thay thế của toán tử * không đệ quy vô hạn, chúng ta kết thúc với dữ liệu kiểu i32, phù hợp với 5 trong assert_eq! trong Listing 15-9.

Chuyển đổi Giải tham chiếu Ngầm với Hàm và Phương thức

Chuyển đổi giải tham chiếu (Deref coercion) chuyển đổi một tham chiếu đến một kiểu triển khai trait Deref thành một tham chiếu đến một kiểu khác. Ví dụ, chuyển đổi giải tham chiếu có thể chuyển đổi &String thành &strString triển khai trait Deref sao cho nó trả về &str. Chuyển đổi giải tham chiếu là một tiện ích Rust thực hiện trên các đối số cho hàm và phương thức, và chỉ hoạt động trên các kiểu triển khai trait Deref. Nó xảy ra tự động khi chúng ta truyền một tham chiếu đến giá trị của một kiểu cụ thể làm đối số cho một hàm hoặc phương thức mà không khớp với kiểu tham số trong định nghĩa hàm hoặc phương thức. Một chuỗi các lệnh gọi đến phương thức deref chuyển đổi kiểu chúng ta đã cung cấp thành kiểu mà tham số cần.

Chuyển đổi giải tham chiếu được thêm vào Rust để các lập trình viên viết lệnh gọi hàm và phương thức không cần thêm quá nhiều tham chiếu và giải tham chiếu rõ ràng với &*. Tính năng chuyển đổi giải tham chiếu cũng cho phép chúng ta viết nhiều mã hơn có thể hoạt động cho cả tham chiếu hoặc con trỏ thông minh.

Để thấy chuyển đổi giải tham chiếu trong hành động, hãy sử dụng kiểu MyBox<T> mà chúng ta đã định nghĩa trong Listing 15-8 cũng như triển khai của Deref mà chúng ta đã thêm vào trong Listing 15-10. Listing 15-11 hiển thị định nghĩa của một hàm có tham số là một lát chuỗi.

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {}

Chúng ta có thể gọi hàm hello với một lát chuỗi làm đối số, chẳng hạn như hello("Rust");. Chuyển đổi giải tham chiếu làm cho việc gọi hello với một tham chiếu đến một giá trị kiểu MyBox<String> trở nên khả thi, như trong Listing 15-12.

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&m);
}

Ở đây chúng ta đang gọi hàm hello với đối số &m, là một tham chiếu đến một giá trị MyBox<String>. Vì chúng ta đã triển khai trait Deref trên MyBox<T> trong Listing 15-10, Rust có thể chuyển đổi &MyBox<String> thành &String bằng cách gọi deref. Thư viện chuẩn cung cấp một triển khai của Deref trên String trả về một lát chuỗi, và điều này có trong tài liệu API cho Deref. Rust gọi deref một lần nữa để chuyển đổi &String thành &str, khớp với định nghĩa của hàm hello.

Nếu Rust không triển khai chuyển đổi giải tham chiếu, chúng ta sẽ phải viết mã trong Listing 15-13 thay vì mã trong Listing 15-12 để gọi hello với một giá trị kiểu &MyBox<String>.

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T;

    fn deref(&self) -> &T {
        &self.0
    }
}

struct MyBox<T>(T);

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

fn hello(name: &str) {
    println!("Hello, {name}!");
}

fn main() {
    let m = MyBox::new(String::from("Rust"));
    hello(&(*m)[..]);
}

(*m) giải tham chiếu MyBox<String> thành một String. Sau đó, &[..] lấy một lát chuỗi của String bằng với toàn bộ chuỗi để khớp với chữ ký của hello. Mã này không có chuyển đổi giải tham chiếu khó đọc, viết và hiểu hơn với tất cả các ký hiệu liên quan. Chuyển đổi giải tham chiếu cho phép Rust xử lý các chuyển đổi này cho chúng ta tự động.

Khi trait Deref được định nghĩa cho các kiểu liên quan, Rust sẽ phân tích các kiểu và sử dụng Deref::deref nhiều lần nếu cần để lấy một tham chiếu khớp với kiểu của tham số. Số lần cần thiết để chèn Deref::deref được giải quyết tại thời điểm biên dịch, vì vậy không có hình phạt thời gian chạy khi tận dụng lợi thế của chuyển đổi giải tham chiếu!

Cách Chuyển đổi Giải tham chiếu Tương tác với Tính Thay đổi

Tương tự như cách bạn sử dụng trait Deref để ghi đè lên toán tử * trên tham chiếu không thay đổi, bạn có thể sử dụng trait DerefMut để ghi đè lên toán tử * trên tham chiếu có thể thay đổi.

Rust thực hiện chuyển đổi giải tham chiếu khi nó tìm thấy các kiểu và triển khai trait trong ba trường hợp:

  1. Từ &T đến &U khi T: Deref<Target=U>
  2. Từ &mut T đến &mut U khi T: DerefMut<Target=U>
  3. Từ &mut T đến &U khi T: Deref<Target=U>

Hai trường hợp đầu tiên giống nhau ngoại trừ trường hợp thứ hai triển khai tính thay đổi. Trường hợp đầu tiên nêu rằng nếu bạn có một &T, và T triển khai Deref đến một kiểu U nào đó, bạn có thể nhận được một &U một cách trong suốt. Trường hợp thứ hai nêu rằng sự chuyển đổi giải tham chiếu tương tự cũng xảy ra cho tham chiếu có thể thay đổi.

Trường hợp thứ ba phức tạp hơn: Rust cũng sẽ chuyển đổi một tham chiếu có thể thay đổi thành một tham chiếu không thay đổi. Nhưng điều ngược lại không khả thi: tham chiếu không thay đổi sẽ không bao giờ chuyển đổi thành tham chiếu có thể thay đổi. Vì quy tắc mượn, nếu bạn có một tham chiếu có thể thay đổi, tham chiếu có thể thay đổi đó phải là tham chiếu duy nhất đến dữ liệu đó (nếu không, chương trình sẽ không biên dịch). Chuyển đổi một tham chiếu có thể thay đổi thành một tham chiếu không thay đổi sẽ không bao giờ phá vỡ quy tắc mượn. Chuyển đổi một tham chiếu không thay đổi thành một tham chiếu có thể thay đổi sẽ yêu cầu tham chiếu không thay đổi ban đầu là tham chiếu không thay đổi duy nhất đến dữ liệu đó, nhưng quy tắc mượn không đảm bảo điều đó. Do đó, Rust không thể đưa ra giả định rằng việc chuyển đổi một tham chiếu không thay đổi thành một tham chiếu có thể thay đổi là khả thi.