TL;DR

  • 所有したデータと、そのデータへの参照を同じ構造体に持つ自己参照データ構造は、生存期間のルールで表せずコンパイルできない。
  • 自己参照データ構造は避けるか、難しさをカプセル化してくれるクレートを探す。

内容

項目15 借用チェッカを理解しようのうち、自己参照データ構造についての話である。

なぜコンパイルできないか

自己参照データ構造は、所有したデータと、そのデータへの参照が混ざったデータ構造である。次のような定義は、生存期間のルールに反するのでコンパイルできない。

struct SelfRef {
    text: String,
    title: Option<&str>,
}

参照にはライフタイム注釈が必要で、それを持つデータ構造全体にもライフタイム注釈が必要になる。しかし、ライフタイム注釈を付けると、SelfRef は外部の何かを参照していると見なされる。実際には title は SelfRef 内部の text を指しているので、「所有したデータとそのデータへの参照」という意図を表せなくなる。

仮に上のコードがコンパイルできたとしても、ムーブが起きると、参照が指しているメモリアドレスが無効になる(ダングリング)ことがある。

let mut s = SelfRef { /* ... */ };
s.title = Some(&s.text[0..5]);
let s2 = s; // ムーブすると、s のメモリアドレスが変わることがある

インデックスで代用する

一番簡単な回避策は、参照の代わりにインデックス(text の中の範囲を表すオフセット)を持つことである。借用チェッカを念頭に置いてデータ構造を設計しようのインデックスを使う方法と同じ考え方である。

struct SelfRef {
    text: String,
    title: Option<std::ops::Range<usize>>,
}

ただし、インデックスは text と整合しなくなることがあり、存在しない範囲を指すおそれがある。

async と Pin

自己参照データ構造は、意識していなくても async を使うときに生まれている。コンパイラは async のコードブロックをクロージャのようなものにまとめる。クロージャは、コードと、そのコードが動く環境の一部をキャプチャしたものを含む。キャプチャした環境には値そのものと値への参照の両方が含まれるので、これは実質的に自己参照データ構造である。

標準ライブラリは std::pin::Pin を使い、キャプチャした値を特定のメモリアドレスに固定することで、自己参照データ構造を実現している。したがって Pin<T> は自己参照データ構造を作る方法の 1 つになるが、正しく理解するのが難しいので、公式ドキュメントをしっかり読む必要がある(std::pin::Pin)。

以上から、自己参照データ構造は避けるのがよい。どうしても必要なら、難しさをカプセル化してくれるクレート(たとえば ouroboros)を探す。

キーワード