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



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

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

「指標」的解參考

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

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

舉例來說:

fn main() {
    let mut num = 123i32;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;
}

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

fn main() {
    let mut num = 123i32;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    println!("{}", *r1);
    println!("{}", *r2);
}

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

fn main() {
    let mut num = 123i32;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
        println!("{}", *r1);
        println!("{}", *r2);
    }
}

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

123
123

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

let address = 0x012345usize;
let r = address as *const i32;

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

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

fn main() {
    let num = 0x10000000FFFFFFFFi64;
    let r1 = &num as *const i64;
    let r2 = r1 as *const u32;

    unsafe {
        println!("{}", *r1);
        println!("{}", *r2);
    }
}

以上程式執行結果如下:

1152921508901814271
4294967295

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

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

舉例來說:

unsafe fn print_something_unsafe() {
    let mut num = 123i32;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    println!("{}", *r1);
    println!("{}", *r2);
}

fn main() {
    print_something_unsafe();
}

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

unsafe fn print_something_unsafe() {
    let mut num = 123i32;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    println!("{}", *r1);
    println!("{}", *r2);
}

fn main() {
    unsafe {
        print_something_unsafe();
    }
}

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

fn print_something_unsafe() {
    let mut num = 123i32;

    let r1 = &num as *const i32;
    let r2 = &mut num as *mut i32;

    unsafe {
        println!("{}", *r1);
        println!("{}", *r2);
    }
}

fn main() {
    print_something_unsafe();
}

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

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

fn split_at(slice: &[i32], mid: usize) -> (&[i32], &[i32]) {
    let len = slice.len();

    assert!(mid <= len);

    (&slice[..mid], &slice[mid..])
}

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

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();

    assert!(mid <= len);

    (&mut slice[..mid], &mut slice[mid..])
}

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

use std::slice;

fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
    let len = slice.len();
    let ptr = slice.as_mut_ptr();

    assert!(mid <= len);

    unsafe {
        (
            slice::from_raw_parts_mut(ptr, mid),
            slice::from_raw_parts_mut(ptr.offset(mid as isize), len - mid),
        )
    }
}

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

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

extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

修改靜態變數的值

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

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

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

static HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}

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

name is: Hello, world!

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

static mut HELLO_WORLD: &str = "Hello, world!";

fn main() {
    println!("name is: {HELLO_WORLD}");
}

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

static mut HELLO_WORLD: &str = "Hello, world!";

fn main() {
    unsafe {
        println!("name is: {HELLO_WORLD}");
    }
}

實作不安全的特性

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

舉例來說:

use std::fmt::Display;

unsafe trait Foo {
    fn print_ptr<T: Display>(&self, p: *const T);
}

struct Bar {}

unsafe impl Foo for Bar {
    fn print_ptr<T: Display>(&self, p: *const T) {
        unsafe {
            println!("{}", *p);
        }
    }
}

fn main() {
    let num = 123i32;
    let r1 = &num as *const i32;

    let b = Bar {};

    b.print_ptr(r1);
}

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

結論

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

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

下一章:進階的生命周期用法