奇妙なサイズの型

私たちは、型は 0 以上の固定サイズを持つと通常考えます。でも常にそうであるとは限りません。

動的サイズの型(DST: Dynamically Sized Type)

実際に、Rust は動的にサイズが決まる型(DST)、静的にはサイズやアラインメントがわからない型、 をサポートしています。 一見すると、これは少し馬鹿げているようです。型をうまく扱うためには、 サイズや型を知らなければいけないですから。 こう考えると DST は通常の型ではありません。サイズが静的にわからないので、 ある種のポインタの裏にしか存在できないのです。 DST を指すポインタは結果的に、普通のポインタと DST を補完する情報(以下で詳しく説明します)から構成される、 太った ポインタになります。

言語が提供する DST のうち重要なものが 2 つあります。trait オブジェクトとスライスです。

Trait オブジェクトは、それが指す Trait を実装するある型を表現します。 元となった型は消去されますが、vtable とリフレクションとによって実行時にはその型を利用することができます。 つまり、Trait オブジェクトを補完する情報とは vtable へのポインタとなります。

スライスとは、単純にある連続したスペース(通常はアレイか Vec)のビューです。 スライスを補完する情報とは、単にポインタが指すエレメントの数です。

構造体は、最後のフィールドとして DST を直接含むことができますが、その構造体自体も DST になります。

// 直接スタックには置けません。
struct Foo {
    info: u32,
    data: [u8],
}

Rust 1.0 時点では、最後のフィールドが正しくアラインメントされていない DST 構造体は正しく動きません

サイズが 0 の型(ZST: Zero Sized Type)

サイズ 0 の型(ZST)は、当然ながら、それ自体ではほとんど価値があありません。 しかし、多くの興味深いレイアウトの選択肢と組み合わせると、ZST が潜在的に役に立つことがいろいろな ケースで明らかになります。Rust は、ZST を生成したり保存したりするオペレーションが no-op に 還元できることを理解しています。 そもそも、ZST はスペースを要求しないので、保存することには意味がありません。 また ZST は 1 つの値しかとらないので、ZST を読み込む操作は、 代わりに無から ZST を作り出すことができ、この操作もスペースを必要としないので no-op と同じです。

究極の ZST の利用法として、Set と Map を考えてみましょう。 Map<Key, Value> があるときに、Set<Key>Map<Key, UselessJunk> の 簡単なラッパーとして実装することはよくあります。 多くの言語では、UselessJunk のスペースを割り当てる必要があるでしょうし、 結果的に使わない UselessJunk を保存したり読み込んだりする必要もあるでしょう。 こういったことが不要であると示すのはコンパイラにとっては難しい仕事でしょう。

しかし Rust では、単に Set<Key> = Map<Key, ()> と言えばいいだけなのです。 Rust は静的な解析で、読み込みや保存が無意味であること、メモリ割当が必要ないことを理解します。 結果として単態化したコードは、HashSet のためにカスタマイズされ、 HashMap を使う場合のオーバーヘッドはなくなります。

安全なコードは ZST について心配する必要はありませんが、危険な コードは サイズ 0 の型を使った時の結果について注意しなくてはなりません。 特に、ポインタのオフセットは no-op になることや、 (Rust のデフォルトである jemalloc を含む)標準的なメモリアロケータは、 サイズ 0 の割り当て要求には nullptr を返すこと (これはメモリ不足と区別がつきません)に注意してください。

空の型

Rust では、インスタンスを生成できない型を宣言することもできます。 こういう型は、型レベルの話にのみ出てきて、値レベルには出てきません。 空の型は、識別子を持たない enum として宣言できます。

enum Void {} // 識別子なし = 空

空の型は、ZST よりもまれにしか使いません。 空の型がもっとも必要になる例としては、型レベルの到達不可能性を示す時です。 例えば、ある API は、一般に Result を返す必要がありますが、 特定のケースでは絶対に失敗しないことがわかっているとします。 Result<T, Void> を返すことで、この事実を型レベルで伝えることが可能です。 Void 型の値を提供することはできないので、この Result は Err になり得ないと静的にわかります。 そのため、この API の利用者は、自信を持って Result を unwrap することができます。

原理的に、Rust ではこの事実をもとに、興味深い解析と最適化が可能です。 たとえば、Result<T, Void>Err にはなり得ないので、 T と表現することができます。以下のコードがコンパイルに通るようにもできるでしょう。

enum Void {}

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

// Err は存在しないので、Ok になることに疑問の余地はありません。
let Ok(num) = res;

ただし、どちらの例も現時点では動きません。 つまり、Void 型による利点は、静的な解析によて、特定の状況が起こらないと確実に言えることだけです。

最後に細かいことを一つ。空の型を指す生のポインタを構成することは有効ですが、 それをデリファレンスすることは、意味がないので、未定義の挙動となります。 つまり、C における void * と同じような意味で *const Void を使うこと出来ますが、 これは、安全にデリファレンスできる型(例えば *const ())と比べて何も利点はありません。