Rust 學習之路─第二十章:不安全的Rust

在最一開始的幾個章節,我們就已經了解到Rust程式語言在編譯階段時,就會進行許多檢查,使得程式在通過編譯後,進入執行階段時,能以有效又安全的方式運行。然而,在某些情況下,通常是處理跟作業系統和硬體有關的底層行為時,使用正常能夠確保安全性的Rust程式碼並無法實現出我們要的功能,此時就會需要使用到可能會讓程式變得不安全的開發方式。

「unsafe」關鍵字,可以使一部份的程式進入不安全模式,在這個模式下,可以直接進行「指標」的解參考、呼叫不安全的函數或是方法、修改靜態變數的值、實作不安全的特性。使用「unsafe」關鍵字,就是在告訴Rust編譯器:「我知道自己正在做什麼,你不需要管太多。」此時確保程式安全性的責任就在開發者的身上了。

由於使用「unsafe」關鍵字來開發Rust程式可能會遭遇到程式安全性的問題,因此如非必要,儘量不要使用「unsafe」關鍵字來寫程式,但如果不得已一定要用的話,最好精簡「unsafe」關鍵字程式區塊中的程式碼,只把真的需要在不安全模式下才能執行的程式放進去。這樣的話,後續如果發現到程式有出現記憶體洩漏等安全性問題,在查找出問題的程式碼時,就可以很快地在「unsafe」關鍵字中,少量的程式敘述區塊裡,找到發生問題的地方。

「指標」的解參考

在之前的章節中,我們使用參考或是智慧型指標,來表示值在記憶體中的位址。Rust程式語言可以保證使用參考或是智慧型指標指到的記憶體位置,其儲存的資料一定能對應到正確的型別。舉例來說,我們無法將「i64」數值的參考,轉型成「f64」數值的參考來使用;也無法將「i32」數值的參考,轉型成「i64」數值的參考來使用,因為很明顯,這兩種型別所使用的記憶體空間大小根本不同,如果Rust允許這樣的轉型,將會去使用到原本就不屬於這個數值的記憶體空間。

但是在Rust的不安全模式下,我們可以利用「原始指標」(raw pointer)直接存取記憶體。原始指標也分為可變和不可變。不可變指標的型別可以寫成「*const T」,可變指標的型別可以寫成「*mut T」,T為其所指到的資料型別,例如「i32」型別的不可變指標就是「*const i32」。原始指標忽略擁有權的規則,也因此它沒有辦法自動消滅其所指到的資料來釋放出記憶體,且它也不保證一定會指到有效的記憶體空間。

舉例來說:

以上程式可以通過編譯。沒有使用到「unsafe」關鍵字,只是單純的建立出「num」變數的不可變指標和可變指標。然而,如果直接嘗試對進行解參考的動作,程式就會編譯失敗。例如:

如果我們要進行「指標」的解參考,就必須將程式碼安置在「unsafe」關鍵字所形成的程式區塊中。例如:

以上程式才可以編譯成功,執行結果如下:

123
123

我們也可以將任意整數型別的值當作是指標來使用,而這個整數值就是記憶體位址。舉例來說:

指標的空間大小等同於「isize」或是「usize」型別的的空間大小,因此通常會使用「usize」型別來表示指標。

指標可以自由轉型成其它型別的指標。舉例來說:

以上程式執行結果如下:

1152921508901814271
4294967295

呼叫不安全的函數或是方法

「unsafe」關鍵字也可以放在「fn」關鍵字之前,使該函數或是方法的主體進入不安全模式。同時,不安全的函數或是方法只有在Rust程式語言進入不安全模式的時候才能被呼叫。

舉例來說:

以上程式第12行會編譯失敗,因為「print_something_unsafe」函數是不安全的。如果要讓程式可以編譯成功,不安全的「print_something_unsafe」函數也必須在不安全模式下才能呼叫,因此可以將以上程式改寫如下:

使用「unsafe」關鍵字搭配「fn」關鍵字,將整個函數或是方法變為不安全的,並不是一個最好的用法。就拿上面這個例子來說,整個「print_something_unsafe」函數其實也只有最後兩個程式敘述需要進入不安全模式。因此,將程式改寫如下,會是比較好的作法:

Rust的標準函式庫中,有些函數和方法是不安全的,例如「std::slice」模組的「from_raw_parts」和「from_raw_parts_mut」函數。「from_raw_parts」函數可以將不可變指標轉為陣列的不可變參考(切片),「from_raw_parts_mut」函數可以將可變指標轉為陣列的可變參考(切片)。

舉一個實際必須應用指標的例子好了。現在我們想要實作一個「split_at」函數,這個函數可以將某個「i32」型別的陣列,以指定的索引值為中心,切割成兩個陣列回傳。我們可以寫出以下程式:

那如果我們想要再實作一個「split_at_mut」函數,這個函數與「split_at」函數雷同,只是它改成使用「i32」型別的可變參考的話,要怎麼寫呢?我們可能會寫出以下程式:

然而,這段程式無法通過編譯,因為一個值在一個scope下,同時只能有一個可變參考,這是我們之前學到的擁有權規則。為了要能夠實作出「split_at_mut」函數,我們可以使用「std::slice」模組提供的不安全的「from_raw_parts_mut」函數來改寫程式。如下:

切片提供了「as_mut_ptr」方法,可以取得切片的可變指標。可變指標也有提供「offset」方法,可以進行記憶體位置的位移計算,回傳新的指標。由「unsafe」關鍵字組成的程式敘述區塊,整體可以看作是表達式。

Rust程式語言也將其它程式語言的函式庫引用進來使用,而這樣的用法是屬於不安全的。例如,若要引用某個C的函數庫提供的「abs」函數,程式如下:

修改靜態變數的值

先前我們已經學過了Rust的常數使用方式,也知道常數可以被宣告在任何的scope,包含全域的scope。現在要來介紹一個類似常數的東西,那就是「靜態變數」(static variable)。常數和靜態變數有以下幾點的不同:

1. 常數使用「const」關鍵字來宣告,靜態變數使用「static」關鍵字來宣告。
2. 常數代表的是值,靜態變數代表的是值在記憶體中的位址。
3. 常數的值可以複製,靜態變數的值則永遠只有那一個。
4. 常數會在程式編譯時就會把它對應的值取代至有用到它的程式敘述中,因此可能雖然是使用同樣的常數,值卻不在相同的記憶體位址(不同實體)。靜態變數的值則會在程式執行的時候儲存到記憶體中,使用相同靜態變數時,都是存取相同的記憶體位址中的值(相同實體)。
5. 常數只有不可變的,靜態變數有可變的和不可變的。
6. 常數不能接受靜態變數的值,靜態變數可以接受常數的值。

存取不可變的靜態變數是安全的,舉例來說:

以上程式可以通過編譯並輸出:

name is: Hello, world!

存取可變的靜態變數是不安全的,舉例來說:

以上程式會編譯失敗,需修改成如下:

實作不安全的特性

除了函數和方法可以加上「unsafe」關鍵字使其變成不安全的之外,特性也是可以的。加上「unsafe」關鍵字的特性效果就只是提醒實作這個特性的實作者要謹慎地處理程式,避免發生因實作方式必須進入不安全模式而可能會導致的問題。也就是說,不安全的特性並不是指這個特性的所有函數或是方法都是不安全的,而是實作者必須要適當地使用不安全模式來實作它。而如果要實作不安全的特性的話,在「impl」關鍵字前也必須要加上「unsafe」關鍵字。

舉例來說:

以上程式,定義了一個「Foo」特性,其有一個「print_ptr」方法,用來印出指標指到的值。由於我們知道,要解參考指標,不管用什麼樣的方式實作,都必須要讓程式進入不安全模式。因此我們認定這個特性必須要謹慎地實作,才不會讓程式有安全性上的問題,所以將其加上「unsafe」關鍵字。這樣一來我們自己或是其它Rust程式開發者日後若要實作這個「Foo」特性,也必須在「impl」關鍵字前加上「unsafe」關鍵字,看到「unsafe」關鍵字就知道這個特性需要小心謹慎地利用Rust程式語言的不安全模式來實作程式了!

結論

透過「unsafe」關鍵字,程式碼在不安全模式下就像是有了超能力一樣,可以完成平常做不到的事情,也就是直接進行指標的解參考、呼叫不安全的函數或是方法、修改靜態變數的值、實作不安全的特性這四大項目啦!

下一章節,我們將會學習進階的生命周期用法。

關於作者

Magic Len

Magic Len

各位好,我是Magic Len,是這網站的管理員。我是台灣台中大肚山上人,畢業於台中高工資訊科和台灣科技大學資訊工程系,曾在桃機航警局服役。我熱愛自然也熱愛科學,喜歡和別人分享自己的知識與經驗。如果你有興趣認識我,可以加我的Facebook,並且請註明是從MagicLen來的。

相關文章