[Rust] &Option<T> hay Option<&T> ?
Bài này chúng ta sẽ tìm hiểu về &Option<T>
và 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>
và 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ểuOption
màOption
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 trongT
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ạnNone
Ở 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
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
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ểmOption<&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_a
và data_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àmcrunch
. Chúng ta có thể sử dụngmap
trongOption
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ảnBox<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>
sangOption<&T>
hoặcOption<&mut T>
sử dụngas_ref
vàas_mut
Option<&T>
có được coi như là con trỏ đến dữ liệuT
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ànhNone
cho bạn.- Sử dụng
Option<&T>
bạn có thể sử dụngas_deref
và sau đó sử dụng một số hàmfilter
phía sau, còn với phiên bản&Option<T>
thì không thể do luật ownership