DST 和 ZST 兩種願望一次滿足

Wu Yu Wei published on
7 min, 1202 words

我們知道 Rust 大部分情況下要求要知道型態的 size 與 alignment,不過同時也支援一些特殊的型態可以忽視這些條件。首先我們最常遇見的就是 Dynamically Sized Types (DSTs) 它們就不會靜態地知道 size 與 alignment,所以它們得透過指標 (pointer) 指向它們才得以存在,這樣的指標是一個寬指標包含一個指針以及一些額外資訊來滿足 Rust 的要求。而這樣的型態也許沒察覺到但其實我們已經遇到而且也用到非常多次,其中最主要的兩大型態就是 trait object 和 slice,更多詳情可以參考 Rustonomicon 的解釋。在平時使用泛型時我們可以用 ?Sized 來指定 DST,以下是將 trait 的 associate type 指定為 DST 的範例:

trait EncodedBuf {
  type Slice: ?Sized;

  fn new() -> Self where Self: Sized;

  /// View the bytes in this buffer as a slice
  fn as_slice(&self) -> &Self::Slice;

  //...
}

當然 DST 實際上在很多函式庫中的使用都是很受限的,大部分當然對型態的要求都是 Sized。在標準函式庫中我們也不乏看到諸多類似的設計像是 &str 之於 String&[] 之於 VecPath 之於 PathBuf 等等。前者均為 DST 都是設計成不擁有其數據的引用 (reference) 型態,需要用後者才能獲得所有權並作為可變 ( mutable) 數據來使用。

而另一方面 Rust 也還支援 Zero Sized Types (ZSTs) 顧名思義就是沒有大小的型態,當然一開始想到的話的確沒有任何意義。當然對於編譯也是如此,所以 Rust 在編譯時可以將一些型態轉為 ZST 並讓它們的動作最終變成只有 nop。原因很簡單,一來是它沒有大小,開空間給它們來儲存沒有任何意義,二來它們也只會有一種值甚至可以憑空產生,所以直接轉為 nop 的確很合適。我們最常用到的 ZST 大概就非 empty tuple () 莫屬,常常用在 Enum 像是 Result 的 mathc 配對等等。

fn return_ok() -> Result<(), io::Error> {
  Ok(())
}

既然知道有這兩種型態後,現在就讓我們用上面的 EncodedBuf trait 嘗試一些有趣的東西,當然還有很多方法和 trait 沒寫上去,不過實作均類似所以不贅述:

pub struct NewByte([()]);

impl NewByte {
    unsafe fn make(ptr: *const u8, offset: usize, len: usize) -> *const Self {
        std::mem::transmute((ptr.offset(offset as isize), len))
    }

    unsafe fn ptr(&self, index: usize) -> *const u8 {
        (self.0.as_ptr() as *const u8).offset(index as isize)
    }

    pub fn slice(&self, range: Range<usize>) -> &Self {
        assert!(range.end >= range.start && range.end <= self.len());
        unsafe { &*Self::make(self.ptr(0), range.start, range.end - range.start) }
    }

    //...
}

pub struct NewByteBuf(Vec<u8>);

impl EncodedBuf for NewByteBuf {
    type Slice = NewByte;

    fn new() -> Self {
        Self(Vec::new())
    }

    pub fn as_slice(&self) -> &Self::Slice {
        unsafe { &*Self::Slice::make(self.0.as_ptr() as _, 0, self.0.len()) }
    }

    //...
}

簡單來說,我們嘗試定義兩種新的型態而他們的關係就類似 Path/PathBuf,它們的長度可能就不是一般的 byte,也許是 3 bits 就一個 byte,不過為了方變我們還是先用 8 bits = 1 byte 作為範例。接著有趣的地方是 NewByte,這是一個 DST 包住了 [()]:一個只包含 zero-sized item 的陣列而且沒有定義長度。由於對 DST 的引用是個胖指針 (我們可以下 assert_eq!(16, std::mem::size_of::<&[u8]>()); 來確認這點),也就是 8 bytes 的指標加上 8 bytes 的長度,我們可以利用這點來儲存 bit offset。我們拿了一個 zero-sized 的 slice 型態,Rust 不必知道 slice 的長度,因為對 ZST 而言永遠會是 0 且 constant,所以長度這欄位變成沒有使用到,我們便能用此來位我們的型態來定義自己想要的長度了。所以神奇的事情發生了 NewByte 同時是 dynamically-sized 以及 zero-sized!

DST 與 ZST 個別都會帶來一些有趣的特性,舉例來說有 DST 的性質的話,因為本身是指標這樣一來 NewTypeBuf 想要 deref 的話就可能變成 NewType,標準函式庫中的 PathBufPath 就是這種關係。而如果 ZST 的話,由於很多動作最終能夠成為 nop,這讓我們能更直接地存取到我們想使用的位置。當然這在一般狀況下的確不須用,而且用到這樣子的黑魔法的確需要處理並確保能解決 unsound 的問題,像是使用 miri 反覆確認。不過當我們要處理的資料屬於非常低階,當占用到一點空間都成為關鍵性的考量時,這的確是非常有趣的使用方法之一。我們可以在像是 bitvecdashmap 等這類的 crate 看到這樣子類似的實作應用。