Bài này chúng ta sẽ tìm hiểu về &Option<T>Option<&T>.Như các bạn đã biết thì trong Rust chúng ta có best practice là hay trả về Option hoặc Result enum. Việc hiểu rõ cũng như thao tác biến đổi qua lại giữa các kiểu này dường như là kỹ năng bắt buộc. Bài hôm này chúng ta sẽ tìm hiểu về &Option<T>Option<&T> ? Trong thực tế chúng ta nên sử dụng cài nào. Chủ đề này chỉ là một vấn đề nhỏ, nó hơi tiểu tiết nhưng thực sự hấp dẫn. Nó cũng là chủ đề thực sự xoắn não rất khó diễn đạt tường minh. Mình đã phải tham khảo từ rất nhiều nguồn để với hi vọng giải thích bằng ngôn ngữ nông dân dễ hiểu nhất.

&Option<T> hay Option<&T> là gì ?

Đầu tiên làm rõ 2 kiểu dữ liệu này:

  • &Option<T>: Nghĩa là "Tôi sẽ đưa cho bạn một tham chiếu tới kiểu OptionOption này chứa dữ liệu bên trong nó. Nhớ rằng, Option này có thể là None
  • Option<&T>: Nghĩa là "Tôi sẽ đưa cho bạn một tham chiếu tới cái gì đó bên trong T nếu tôi có nó. Còn trong trường hợp tôi không có nó, tôi sẽ trả về cho bạn None

Ở trong trường hợp thứ Option<&T> có thể quan niệm Option<&T> là dạng con trỏ để kiểu T nếu như có tham chiếu, trong trường hợp

Bạn sẽ thắc mắc là tại sao quan niệm như vậy được, bởi nó cũng là 1 Option kia mà ?. Mà đã là 1 Option thì khi biểu thị trong bộ nhớ nó sẽ có phần trông giống như này

Layout của Option<T> và None

Nhưng theo như mình tìm hiểu nhờ có cái gọi là niche optimize, khi Option<&T> là một kiểu Some(&t) nào đó, nó sẽ tối ưu bằng cách bỏ qua phần tag và trỏ đến vùng chứa địa chỉ dữ liệu T (Không phải dữ liệu). Trong trường hợp nếu như chúng ta không có giá trị, giá trị Option<&T> cũng sẽ trở thành None bởi compiler nhận thấy rằng, tham chiếu tới giá trị là 0 thì có lẽ chẳng có gì bên trong Option này, nó sẽ trả về về giá trị None cho bạn.

Nghe hơi rắc rối phải không?

Layout

Đây là layout memory của đó để chúng ta dễ hình dung

Layout của &Option<T> và Option<&T>

Như bạn thấy đó, một enum được biển

Vậy thì trong thực tế chúng ta sẽ dùng kiểu nào nhiều hơn và kiểu nào mới là kiểu thực sự chúng ta quan tâm nhiều hơn

Nên sử dụng &Option<T> hay Option<&T> trong thực tế?

Câu trả lời là chúng ta nên thích dùng Option<&T> hơn trong là &Option<T>. Bởi sẽ có nhiều điểm Option<&T> nhiều điểm thuận tiện hơn trong việc thao tác.

Cùng xem xét đoạn kỹ đoạn code sau

Phiên bản unmutable và ví dụ

#[derive(Debug)]
pub struct Data {}

impl Data {
    pub fn crunch(&self) -> i32 {
        100
    }
}

Chúng ta khai báo Data là một struct và có một hàm crunch. Tiếp theo chúng ta khai bao một Widget chứ một Option<Data> với 2 hàm data_adata_b bên trong

#[derive(Debug, Default)]
struct Widget(Option<Data>);

impl Widget {
    pub fn data_a(&self) -> &Option<Data> {
    &self.0
    }
    pub fn data_b(&self) -> Option<&Data> {
        self.0.as_ref()
    }
}

Phiên bản đầu tiên của chúng ta có signature như sau pub fn data_a(&self) -> &Option<Data>. Hàm này như thông thường tham tham chiếu tới một Option<Data>. Hàm thứ 2 data_b(&self) -> Option<&Data> trả về một Option<&Data> thông qua sử dụng as_ref.

Thông thường

Mục đích của hàm này là chúng ta có thể lấy được Data bên trong và thực hiện gọi lời hàm crunch phía trên. Nhưng rõ ràng, với phiên bản đầu tiên này chúng ta có một tham chiếu đến Option<T> chứ không hề tham chiếu tới Data bên trong.

  • Đầu tiên khẳng định với khi Widget khởi taọ giá trị mặc định. Cả 2 hàm đề trả về cùng giá trị None
pub fn run() {
    let widget = Widget::default();
    let a: &Option<Data> = widget.data_a();
    let b: Option<&Data> = widget.data_b();
    assert_eq!(a.is_some(), b.is_some());
    println!("{:?}, {:?}", a, b); // None, None
}
  • Khi sử dụng hai hàm này, mục đích của chúng ta là luôn muốn truy cập gía trị bên trong Data để có thể sử dụng hàm crunch . Chúng ta có thể sử dụng map trong Option như sau
source
pub fn map<U, F>(self, f: F) -> Option<U>
where
    F: FnOnce(T) -> U,

Và khi thực hiện nó sẽ như sau với phiên bản đầu tiên:

 let crunch_a = a.map(|data| data.crunch());

Thật không may mắn, compiler không đồng ý và báo lỗi cho chúng ta như sau

cannot move out of `*a` which is behind a shared reference
help: consider calling `.as_ref()` or `.as_mut()` to borrow the type's contentsrustcClick for full compiler diagnostic

Nó bảo rằng không thể move out *a mà đằng shau một shared reference được. Điều này nghĩa là gì? Hãy nhìn chút vào hàm map phía trên, nó sẽ consume self và sử dụng hàm F function để biến một kiểu U. Vì nó consume nên nó sẽ move người gọi nó vào trong hàm closure của hàm map này tức là biến a. Nhưng a chỉ là đi mượn, không hề sở hữu (ownership). Đó là lý đo compiler không đồng ý với bạn. Nó cũng gợi ý cho bạn một cách làm là sử dụng as_ref().

Thử thay đổi

 let crunch_a = a.as_ref().map(|data| data.crunch());

Lúc này compiler sẽ hạnh phúc với cách làm của bạn phía trên. Thực chất khi làm việc trên là bạn cũng đang biến đổi thành Option<&T> trước khi sử dụng map

Vậy là mỗi khi bạn cần sử dụng map chúng ta thường phải sử dụng as_ref hoặc as_mut. Khá là bất tiện

Nhìn vào cái biến thể thứ hai

 pub fn data_b(&self) -> Option<&Data> {
        self.0.as_ref()
    }

Chúng đã gọi hàm as_ref và nó trả về một ownership của một Option và khi gọi hàm map chúng ta hoàn toàn có thể move option này vào closure và không có vấn đề gì cả.

Có vẻ cũng vẫn hơi khó hiểu và hoang đường.

Phiên bản mutable

Nhìn một chút phiên bản mutable giúp chúng ta cũng cố về kiến thức trên và nhìn thấy sự khác nhau trong 2 phiên bản

impl Widget {
 // Đây là một tham chiếu đến đối tượng mà bạn có thể chứa bất kỳ trong đối tượng đó
    // và thậm chí có thể thay đổi nó
    // Tôi đang lưu trữ dữ liệu trong một options và đây là một tham chiếu tới option
    pub fn data_a_mut(&mut self) -> &mut Option<Data> {
        &mut self.0
    }

    // Đây là một tham chiếu tới dữ liệu nếu như chúng ta có dữ liệu
    // Tôi có thể hoặc không có dữ liệu, nhưng nếu tôi có thì nó là một tham chiếu tới dữ liệu
    pub fn data_b_mut(&mut self) -> Option<&mut Data> {
        self.0.as_mut()
    }
}

Ở phiên bản pub fn data_a_mut(&mut self) -> &mut Option<Data> chúng ta trả về một &mut Option<Data>, nghĩa là chúng ta hoàn toàn có thể set lại giá trị của Option về None hoặc bất kỳ gì. Ở phiên bản thứ 2 pub fn data_b_mut(&mut self) -> Option<&mut Data> có một chút ràng buộc. Bạn không thể tự set giá trị Option về None được, bạn chỉ có quyền set lại gía trị nằm trong Option này thôi, với điều kiện bạn phải có nó.

Tương tự chúng ta sử dụng hàm as_mut ở phiên bản thử hai với footprint như sau:

pub fn as_mut(&mut self) -> Option<&mut T>
let mut x = Some(2);
match x.as_mut() {
    Some(v) => *v = 42,
    None => {},
}
assert_eq!(x, Some(42));
  • Giả sử chúng ta có một thay đổi để lưu trữ Data trong vùng heap với phiên bản Box<Data>.
struct Widget(Option<Box<Data>>);
impl Widget {
  pub fn data_a_box(&self) -> &Option<Box<Data>> {
        ????
  }
   pub fn data_a_box(&self) -> &Option<Data> {
        &self.0.as_defef().filter(pred)
  }

}

Chúng ta có thể thêm một số hàm filter để sử dụng mà không phải thay đổi giá trị trả về, trong khi với trường hợp đầu tiên khá khó để xử lý

pub fn filter<P>(self, predicate: P) -> Option<T>
where
    P: FnOnce(&T) -> bool,

Muốn Option<T> ownership


fn i_need_ownership(data: Option<&i32>) {
    let _: Option<i32> = data.map(ToOwned::to_owned);
    let _: Option<i32> = data.cloned();
    let _: Option<i32> = data.copied();
}

Sử dụng ToOwned::to_owned để trả về ownership. Cách làm của hàm này là nó nhận một tham chiếu sau đó, nó copy dữ liệu sang vùng mới vào trả về Option<T> trên vùng nhớ này.

Bạn cũng có thể sử dụng các hàm cloned() , copied để làm điều này. Tiện phải không nào.

Tổng kết

  • Thích Option<&T> hơn là &Option<T>
  • Biến đổi từ Option<T> sang Option<&T> hoặc Option<&mut T> sử dụng as_refas_mut
  • Option<&T> có được coi như là con trỏ đến dữ liệu T bên trong. Tham chiếu bên trong trong Rust là không thể invalid, nhưng trong trường hợp nó là &0 nó có thể tự biến đổi thành None cho bạn.
  • Sử dụng Option<&T> bạn có thể sử dụng as_deref và sau đó sử dụng một số hàm filter phía sau, còn với phiên bản &Option<T> thì không thể do luật ownership