iT邦幫忙

2024 iThome 鐵人賽

DAY 24
0

看完基本的智慧指標 Box<T>,在看其他智慧指標之前,先來看看為什麼 DerefDrop 特徵對智慧指標來說是重要的。

解參考運算子

實作 Deref 特徵可以自訂解參考運算子(dereference operator) * 的行為。
所以要先理解這個運算子是做什麼的。

我們已經知道 & 是取得數值的參考, * 則是追蹤參考指向的數值,就是逆向操作。

以下例子說明 * 的作用:

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

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

注意到 xy 的型別是不同的,y 是參考型別,當我要比較它和數字 5 的時候勢必要把參考轉回對應的數值,這個操作對應的就是*y,這樣就可以從參考取得對應數值 5。

智慧指標解參考

接著我們以 Box<T> 來測試智慧指標的情況。

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

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

目前因為 y 的型別是 Box<i32> ,所以無法和 5 比較,會報錯。

error[E0277]: can't compare `i32` with `&i32`
 --> src/main.rs:6:5
  |
6 |     assert_eq!(5, y);
  |     ^^^^^^^^^^^^^^^^ no implementation for `i32 == &i32`
  |
  = help: the trait `PartialEq<&i32>` is not implemented for `i32`
  = note: this error originates in the macro `assert_eq` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider dereferencing here
 --> /Users/lanshihchun/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/macros/mod.rs:40:35
  |
40|                 if !(*left_val == **right_val) {
  |                                   +

而且很有趣,編譯器直接判定它是參考型別。
針對這個錯誤,我們要做和一般的參考做一樣的事情:解參考。

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

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

這次就可以正常編譯而且執行沒有任何錯誤!
這是因為智慧指標已經幫我們處理好細節,所以可以用和一般參考一樣的方式來解參考,寫法上和一般操參考是一樣的(*)。

自訂型別解參考

那我們如果自訂結構體並且用解參考運算子會發生什麼事?

struct CustomStruct<T>(T);

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

    assert_eq!(5, x);
    assert_eq!(5, *y);  // 這行會導致編譯錯誤
}

這樣寫是編譯不過的,因為沒有定義 * 要怎麼作用在自訂結構體上。

error[E0614]: type `CustomStruct<{integer}>` cannot be dereferenced
 --> src/main.rs:8:19
  |
8 |     assert_eq!(5, *y);
  |                   ^^

如果要取得 5 最簡單的方式當然就是直接 y.0 ,但是這和我們的型別實作有關,等於讀取會有各式各樣的寫法,這不是我們想要的,我們要的是和一般參考統一的操作
如果要和參考一樣的話那我們要先取得值 y.0的參考然後再解參考它,變成這樣 *(&(y.0)),這樣解參考的部分就一樣了。

接著考量到未來實作會有多種型別,但我們希望它們都可以提供一種解參考之前的預處理,這就是Deref 特徵在做的事!

實作 Deref

要實作 Deref 特徵重點在於 deref 方法的實作,在這邊我們要想辦法讓它回傳最初定義泛型型別T對應數值的參考。
我們用把剛才定義的 CustomStruct 實作 Deref 特徵之後變成這樣:

use std::ops::Deref;

struct CustomStruct<T>(T);

impl<T> Deref for CustomStruct<T> {
    type Target = T;  // 定義解引用後的目標類型

    fn deref(&self) -> &Self::Target {
        &self.0  // 返回內部值的參考
    }
}

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

    assert_eq!(5, x);
    assert_eq!(5, *(y.deref())); // 顯式呼叫 deref
    assert_eq!(5, *y); // 等價上面那一行
}

把原本&(y.0) 那段移到 deref 裡面變成是 &self.0,並保持回傳一個參考回去,這樣剩下的問題是每次有實作這種特徵的型別都要多寫 .deref(),但最酷的是 Rust 幫我們處理好了,有實作 Deref 的型別 Rust 會將 * 運算子替換為方法 deref 的呼叫再進行普通的解參考,也就是說 *y 會被替換成 *(y.deref()),所以在解參考的時候我們不需要再去多呼叫 deref

透過 Deref 特徵可以幫不同的型別實作轉換成對應數值參考的方法,而實作這個特徵的智慧指標們在解參考的寫法就和一般參考完全一樣了!
這樣我們就算遇到不同的智慧指標也能用和一般參考一樣的方式來寫程式碼。

隱式強制解參考

強制解參考(Deref coercion) 是 Rust 一種會把有實作 Deref 特徵的型別參考自動轉換成其他型別的參考的行為。
當我們把某個型別的參考傳進函數時,如果該型別的參考和函數定義不同,會進行一系列的 deref 方法呼叫,把參考型別轉成一致的。

比如說在切片的例子:

fn get_preview(s: &str) -> &str {
    &s[0..4]
}

fn main() {
    let sentence = String::from("Rust programming is fun");
    let preview = get_preview(&sentence);
    // let preview = get_preview(&(sentence.deref())); // 等價上一行
    println!("Preview: {}", preview); // Preview: Rust
}

之前有提過String 也是一種智慧指標,因此也有實作 Deref 特徵。
Stringderef 實作會將 &String 解引用成 &str,這讓我們可以對 String 使用很多設計給 &str 的函數或方法。

上面程式碼可以成立的原因就在於強制解參考,不然我們就必須自己去呼叫 deref 方法。

而會說一系列的 deref 呼叫是因為隱式強制解參考可以呼叫不只一次,Rust 會分析怎麼呼叫可以取得和函數要求相符的參考型別。

例如延伸我們上面的例子,把 String 裝進我們的 CustomStruct

fn hello(name: &str) {
    println!("Hello, {name}!");
}
fn main() {
    let m = CustomStruct(String::from("Rust"));
    hello(&m); // hello(&(&(*(m.deref())).deref()));
}

沒有強制解參考的話會寫成註解那樣,非常複雜:
*(m.deref()) 會拿到 String 型別的數值,&(*(m.deref()))就是拿到這個數值的引用 &String ,然後再透過 String 自己的 deref 方法得到 &str,才符合函數的定義。

這樣就十分了解隱式強制解參考有多重要了。

另外編譯器會分析 deref 需要呼叫的次數並自動插入必要的 deref 呼叫使型別匹配,所有的轉換都在編譯時完成,所以大部分情況可以視為不會影響運行時性能

不過即使單次呼叫的成本很小,過度依賴 Deref 可能導致不必要的間接性,過多且頻繁的 deref 呼叫累積起來可能還是會對性能有輕微影響,極端情況直接使用具體型別可能會更高效。

強制參考與可變性

Rust 總共有三種情況會進行強制解參考:

  • 從 &T 到 &U 且 T: Deref<Target=U>
  • 從 &mut T 到 &mut U 且 T: DerefMut<Target=U>
  • 從 &mut T 到 &U 且 T: Deref<Target=U>

相對於 Deref 特徵用來覆蓋不可變參考的 * 運算子, DerefMut 特徵是用來覆蓋可變參考的 * 運算子。
白話來說,對應的情況就是:

  • T 不可變參考轉為 U 不可變參考
  • T 可變參考轉為 U 可變參考
  • T 可變參考轉為 U 不可變參考

可變參考轉為不可變參考之所以可行,是因為可變參考一定是該資料的唯一參考,轉換成不可變並不會違反借用規則。
反之,沒有第四種情況是因為可變參考轉為不可變參考的話要確保它是唯一參考,不過這件事無法被確保,因為在參考規則中不可變參考可能同時有多個,所以不能從不可變轉為可變參考。

Drop 的回顧及補充

Drop 特徵我們曾經在所有權介紹過,那時候是用它來觀察記憶體被釋放前的動作。

它實務上的應用其實用來做釋放記憶體或其他資源的操作,Drop 特徵讓我們可以把這類的操作集中管理,而且會在數值離開作用域時自動執行,這樣就不用在每個地方加上清理的程式碼,簡化程式碼的同時也避免忘記處理這類資源造成資源浪費,甚至影響系統過載或崩潰。實務上資料庫連線、鎖等等都是適用的對象。

透過 std::mem::drop 提早釋放數值

我們無法直接取消 Rust 自動 drop 的行為,因為它設計上本來就是要自動處理的。
不過反而是有可能出現希望提早處理的情況。例如在同一個作用域的時候有用到鎖,可能需要提早解鎖來讓後續其他程式碼能拿到這個鎖,而沒辦法等到它在作用域結束才自動釋放。

Rust 不允許我們直接顯式呼叫該實體的 drop 方法,原因在於作用域結束還是會自動呼叫一次,這樣可能會導致重複釋放(double free)的錯誤,Rust 可能會嘗試再次清除相同的數值。

作為替代方案,Rust 提供 std::mem::drop 函數讓我們傳入想要強制提早釋放的數值,這個函數也被預載入(prelude)所以我們可以直接呼叫它而不用額外引入它。

use std::sync::Mutex;

fn main() {
    let mut m = Mutex::new(5);
    let mut guard = m.lock().unwrap();

    // ... 執行一些需要鎖的程式碼 ...

    // 提早釋放鎖
    drop(guard);

    // 這裡其他程式碼就可以再次獲取鎖了
}

std::mem::drop 不會造成重複釋放的原因在於,這是一種移交所有權的行為。把變數傳給 drop後變數的所有權被轉給這個函數,而這個函數會立即釋放該變數並清理資源。所有權機制依然確保所有參考永遠有效,會讓那個變數在當前作用域內無法再次被使用,所以我們也不必擔心會意外清理到仍在使用的數值。
同時因為所有權已經被移交且記憶體被釋放,當作用域結束時,變數也早就不再有效,所以不會再次觸發自動釋放。

結語

這邊我們介紹完DerefDrop 特徵,當然不只智慧指標可以實作它們,只是智慧指標的功能和這兩個特徵息息相關,了解它們讓我們也更了解智慧指標的行為,那我們就可以繼續看更複雜的其他智慧指標了!


上一篇
Day23 - 智慧指標:Box<T>
下一篇
Day25 - 智慧指標 :Rc<T>
系列文
螃蟹幼幼班:Rust 入門指南30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言