1. 程式人生 > 其它 >Rust 中的資料佈局--非正常大小的型別

Rust 中的資料佈局--非正常大小的型別

非正常大小的型別

大多數的時候,我們期望型別在編譯時能夠有一個靜態已知的非零大小,但這並不總是 Rust 的常態。

Dynamically Sized Types (DSTs)

Rust 支援動態大小的型別(DST):這些型別沒有靜態(編譯時)已知的大小或者佈局。從表面上看這有點離譜:Rust 必須知道一個東西的大小和佈局,才能正確地進行處理。從這個角度上看,DST 不是一個普通的型別,因為它們沒有編譯時靜態可知的大小,它們只能存在於一個指標之後。任何指向 DST 的指標都會變成一個包含了完善 DST 型別資訊的胖指標(詳情見下方)。

Rust 暴露了兩種主要的 DST 型別:

  • trait objects:dyn MyTrait
  • slices:[T]str及其他

Trait 物件代表某種型別,實現了它所指定的 Trait。確切的原始型別被刪除,以利於執行時的反射,其中包含使用該型別的所有必要資訊的 vtable。補全 Trait 物件指標所需的資訊是 vtable 指標,被指向的物件的執行時的大小可以從 vtable 中動態地獲取。

一個 slice 只是一些只讀的連續儲存——通常是一個數組或Vec。補全一個 slice 指標所需的資訊只是它所指向的元素的數量,指標的執行時大小隻是靜態已知元素的大小乘以元素的數量。

結構實際上可以直接儲存一個 DST 作為其最後一個欄位,但這也會使它們自身成為一個 DST:

 

// 不能直接儲存在棧上
struct MySuperSlice {
    info: u32,
    data: [u8],
}

如果這樣的型別沒有方法來構造它,那麼它在很大程度上來看是沒啥用的。目前,唯一支援的建立自定義 DST 的方法是使你的型別成為泛型,並執行非固定大小轉換(unsizing coercion)

 
struct MySuperSliceable<T: ?Sized> {
    info: u32,
    data: T,
}

fn main() {
    let sized: MySuperSliceable<[u8; 8]> = MySuperSliceable {
        info: 17,
        data: [0; 8],
    };

    let dynamic: &MySuperSliceable<[u8]> = &sized;

    // 輸出:"17 [0, 0, 0, 0, 0, 0, 0, 0]"
    println!("{} {:?}", dynamic.info, &dynamic.data);
}

(是的,自定義 DST 目前僅僅是一個基本半成品的功能。)

零大小型別 (ZSTs)

Rust 也允許型別指定他們不佔空間:

 

struct Nothing; // 無欄位意味著沒有大小

// 所有欄位都無大小意味著整個結構體無大小
struct LotsOfNothing {
    foo: Nothing,
    qux: (),      // 空元組無大小
    baz: [u8; 0], // 空陣列無大小
}

就其本身而言,零尺寸型別(ZSTs)由於顯而易見的原因是相當無用的。然而,就像 Rust 中許多奇怪的佈局選擇一樣,它們的潛力在通用語境中得以實現。在 Rust 中,任何產生或儲存 ZST 的操作都可以被簡化為無操作(no-op)。首先,儲存它甚至沒有意義——它不佔用任何空間。另外,這種型別的值只有一個,所以任何載入它的操作都可以直接憑空產生它——這也是一個無操作(no-op),因為它不佔用任何空間。

這方面最極端的例子之一是 Set 和 Map。給定一個Map<Key, Value>,通常可以實現一個Set<Key>,作為Map<Key, UselessJunk>的一個薄封裝。在許多語言中,這將需要為無用的封裝分配空間,並進行儲存和載入無用封裝的工作,然後將其丟棄。對於編譯器來說,證明這一點是不必要的,是一個困難的分析。

然而在 Rust 中,我們可以直接說Set<Key> = Map<Key, ()>。現在 Rust 靜態地知道每個載入和儲存都是無用的,而且沒有分配有任何大小。其結果是,單例化的程式碼基本上是 HashSet 的自定義實現,而沒有 HashMap 要支援值所帶來的開銷。

安全的程式碼不需要擔心 ZST,但是不安全的程式碼必須小心沒有大小的型別的後果。特別是,指標偏移是無操作的,而分配器通常需要一個非零的大小

請注意,對 ZST 的引用(包括空片),就像所有其他的引用一樣,必須是非空的,並且適當地對齊。解引用 ZST 的空指標或未對齊指標是未定義的行為,就像其他型別的引用一樣。

空型別

Rust 還允許宣告不能被例項化的型別。這些型別只能在型別層討論,而不能在值層討論。空型別可以通過指定一個沒有變體的列舉來宣告:

 

enum Void {} // 沒有變體的型別 = 空型別

空型別甚至比 ZST 更加邊緣化。空型別的主要作用是為了讓某個型別不可達。例如,假設一個 API 需要在一般情況下返回一個結果,但一個特定的情況實際上是不可能的。實際上可以通過返回一個Result<T, Void>來在型別級別上傳達這個資訊。API 的消費者可以放心地 unwrap 這樣一個結果,因為他們知道這個值在本質上不可能是Err,因為這需要提供一個Void型別的值。

原則上,Rust 可以基於這個事實做一些有趣的分析和優化,例如,Result<T, Void>只表示為T,因為Err的情況實際上並不存在(嚴格來說,這只是一種優化,並不保證,所以例如將一個轉化為另一個仍然是 UB)。

比如以下的例子,曾經是可以編譯成功的:

 

enum Void {}

let res: Result<u32, Void> = Ok(0);

// 不存在 Err 的情況,所以 Ok 實際上永遠都能匹配成功
let Ok(num) = res;

但現在,已經不讓這麼玩兒了。

關於空型別的最後一個微妙的細節是,構造一個指向它們的原始指標實際上是有效的,但對它們的解引用是未定義行為,因為那是沒有意義的。

我們建議不要用*const Void來模擬 C 的void*型別。很多人之前這樣做,但很快就遇到了麻煩,因為 Rust 沒有任何安全防護措施來防止用不安全的程式碼來例項化空型別,如果你這樣做了,就是未定義行為。因為開發者有將原始指標轉換為引用的習慣,而構造一個&Void是未定義行為,所以這尤其成問題。

*const ()(或等價物)對void*來說效果相當好,可以做成引用而沒有任何安全問題。它仍然不能阻止你試圖讀取或寫入數值,但至少它可以編譯成一個 no-op 而不是 UB。

外部型別

有一個已被接受的 RFC 來增加具有未知大小的適當型別,稱為 extern 型別,這將讓 Rust 開發人員更準確地模擬像 C 的void*和其他“宣告但從未定義”的型別。然而,截至 Rust 2018,該功能在size_of_val::<MyExternType>()應該如何表現方面遇到了一些問題