Drop Checkerの規則をちゃんと理解する

概要: Drop CheckerはRustのボローチェッカに付属する追加の検査器で、コンパイラが自動で挿入するdrop処理の安全性を保証するためのものである。これの詳細な規則とその正当性を理解したかったので自分なりに整理した。

dropckの概要についてはRustonomiconの説明とか以前の記事とかを参照。

注意: 本記事の内容はコンパイラの実装との対応をとっていないので実際の実装とは異なる可能性がある。

規格

用語

  • Drop::drop(): ユーザー定義のデストラクタ自体
  • mem::drop_in_place(): 値をデストラクトするために Drop::drop()再帰的に呼び出す一連の手続き

mem::drop_in_place() の実装は型ごとにコンパイラが生成するが、大雑把に言うと以下のようになる:

struct Foo {
    bar: Bar,
    baz: Baz,
}

fn drop_in_place<T>(foo: &mut T) where T == Foo {
    // Drop::drop() を呼ぶ (存在すれば)
    <Foo as Drop>::drop(foo);
    // 各メンバについて再帰的に呼ぶ
    drop_in_place(&mut foo.bar);
    drop_in_place(&mut foo.baz);
}

問題

mem::drop_in_place() の呼び出しを挿入するとライフタイム条件が壊れる

    {
+------ let x = ..;
| +---- :
| | 'in :
| +---- :
| 'out  << mem::drop_in_place(&mut x); >> // コンパイラが挿入するdrop
+------
    }

このとき、

  • xの型自体に 'in が全く使えないのは困る。これは相互参照するデータを作るために必要。
  • しかし、 drop_in_placeの途中で 'in は使えなくなるから、 drop_in_place'in に関してフルアクセスできる保証はない

解決方法

mem::drop_in_place の中では、 x に関係するライフタイム全てが有効であるとは限らないことにする。例えば、 x: Foo<'a> を使う関数は、通常 'a がまだ有効であることを仮定してよいが、 mem::drop_in_place に関してはこれを仮定できない場合がある。そのかわり、以下のルールに従って、一部のライフタイムに関してだけは、その有効性を仮定してよい。

コンパイラは、それらのライフタイムが上記の 'out まで生存すること (つまり 'in よりも真に長く生存すること) をチェックする。

drop_lifetimes 収集の流れ

mem::drop_in_place<T> で有効性を仮定してよいライフタイムの一覧を drop_lifetimes(T) と呼ぶことにする。 mem::drop_in_place<T> は以下の手順で定義される。

  1. Drop::drop() が触れうるライフタイムを列挙する。
  2. mem::drop_in_place() がどのように再帰的に呼ばれうるかを調べる。
  3. 1の情報を2の構造に沿って再帰的に収集する。

Drop::drop() が触れうるライフタイムの列挙

例として、 Foo<'a, 'b, T, U> という型を考える。この Drop::drop() が触れうるライフタイムの一覧を、以下のように保守的に列挙する。

FooDrop を実装していない場合

Drop で何もしないことがはっきりわかるので、どのライフタイムも必要ではない。

user_drop_lifetimes(Foo<'a, 'b, T, U>) = {}

FooDrop を実装している場合

保守的に、全てのパラメーターのライフタイムを仮定する。

user_drop_lifetimes(Foo<'a, 'b, T, U>) = {'a, 'b} ∪ {Tの全てのライフタイム} ∪ {Uの全てのライフタイム}

FooDrop 実装が #[may_dangle] を持っている場合

unsafe#[may_dangle] を組み合わせることで、ユーザーの責任で「このライフタイムしか使わない」と宣言することができる。例えば、

unsafe impl<'a, #[may_dangle] 'b, T, #[may_dangle] U> Drop for Foo<'a, 'b, T, U> { .. }

の場合、

user_drop_lifetimes(Foo<'a, 'b, T, U>) = {'a} ∪ {Tの全てのライフタイム}

となる。

mem::drop_in_place()再帰的呼び出し構造

この再帰呼び出し構造のことを、単に「型Tが型Uを所有する」ともいう。これは、「mem::drop_in_place<T>() の中で mem::drop_in_place<U>() が呼び出されうる」という関係を近似したものである。例えば、 structenumdrop_in_place 内では、全てのメンバの drop_in_place が呼び出されうる。

実際にコンパイラで検査をするさいは、循環的な所有関係があることに注意する必要がある。

この規則はほぼ上の説明通りだが、以下の2点に注意が必要である。

  • PhantomData<T>T を所有する。
  • [T; 0]T を所有する。

user_drop_lifetimes再帰的に収集する

結局、 drop_lifetimes は以下のように定義される。

drop_lifetimes(T) = { 'a | T は U を所有し、 'a ∈ user_drop_lifetimes(U) である }

最終的な規約

  • <T as Drop>::drop() 内では、 drop_lifetimes(T) (user_drop_lifetimes(T) ではないことに注意) の有効性のみを仮定できる。
    • #[may_dangle] を使わない限りは、自動的に保証される。
  • 上記の規約により、 mem::drop_in_place<T>() も、 drop_lifetimes(T) の有効性のみを仮定すれば安全であることが保証される。
  • 逆に、利用する側では、以下の条件がチェックされる: 型 T の値がdropされるとき、 drop_lifetimes(T) のライフタイムはその値よりも真に長く生存する必要がある。

最後に Rc で復習

Rc は以下のように定義されている。

struct Rc<T: ?Sized> {
    ポインタ,
    _marker: PhantomData<T>,
}

unsafe impl<#[may_dangle] T: ?Sized> Drop for Rc<T> {
    fn drop(&mut self) {
        ...
        mem::drop_in_place::<T>(ポインタ);
        ...
    }
}

ポイントとして、 RcDrop 実装は T#[may_dangle] がついている。これは mem::drop_in_place::<T> を呼んでいるから、一見おかしいように見える。しかし実は問題ない。確かに、 #[may_dangle] の効果により

user_drop_lifetimes(Rc<T>) = {}

となるが、 PhantomData<T> があるので

drop_lifetimes(Rc<T>) = drop_lifetimes(T)

となる。

上の規約で

  • <T as Drop>::drop() 内では、 drop_lifetimes(T) の有効性のみを仮定できる。

  • <T as Drop>::drop() 内では、 user_drop_lifetimes(T) の有効性のみを仮定できる。

と書かなかったのはそのためである。

なお、 RcPhantomData<T>#[may_dangle] をやめて

struct Rc<T: ?Sized> {
    ポインタ,
}

impl<T: ?Sized> Drop for Rc<T> {
    fn drop(&mut self) {
        ...
        mem::drop_in_place::<T>(ポインタ);
        ...
    }
}

としても安全だと思われる。しかし、この場合は

drop_lifetimes(Rc<T>) = drop_lifetimes(T)

ではなく

drop_lifetimes(Rc<T>) = lifetimes(T) (T の全てのライフタイム)

となってしまう。つまり、この状態だとdropckはより厳しい挙動をしてしまう。つまり、この2つの違いは、

  • Tdrop_in_place を呼ぶだけなら、 PhantomData<T> を使って #[may_dangle] T とすることで、 drop_lifetimes(T) の保証のみを得ることができる。
  • T に対してより一般的な処理をするなら、何もしないことで lifetimes(T) の保証を得ることができる。 (そのぶんdropckが厳しくなる)

といえる。

RFC1238時代はどう違ったか

RFC1238とRFC1327の違いは、単に #[may_dangle] の粒度の違いだけである。

例えば、RFC1238では

impl<'a, 'b, T, U> Drop for Foo<'a, 'b, T, U> {
    #[unsafe_destructor_blind_to_params]
    fn drop(&mut self) { .. }
}

と書くと、今でいう

unsafe impl<#[may_dangle] 'a, #[may_dangle] 'b, #[may_dangle] T, #[may_dangle] U> Drop for Foo<'a, 'b, T, U> { .. }

と同じ意味になる。このように #[may_dangle] を一括で適用するしかないので、

unsafe impl<'a, #[may_dangle] 'b, T, #[may_dangle] U> Drop for Foo<'a, 'b, T, U> { .. }

と同等の記述はできない。

RFC0769時代はどう違ったか

RFC0769の時点ではパラメトリシティを用いて、より積極的に安全性を推論していた。パラメトリシティとは、例えば

fn foo<T: Sync>(x: T) { .. }

のような関数があったときに、 foox に対してできることは限られている (具体的には drop するか forget するくらいしかない) という仮定である。

この仮定が成立する理由は、 foox に関して、 Sized + Sync であるという限定的な情報しか持っていないからである。 x: T: Sync だとわかっても、 Sync は何もメソッドを持たないので、特にできることがないということになる。

しかし、現在はこのパラメトリシティという仮定は崩れている。特殊化を用いると、

fn foo<T: Sync>(x: T) {
    // 何もしない
}
fn foo<T: Sync + Foo>(x: T) {
    // TがFooを実装していたときは、何かをする
    x.foo();
}

のように、 T があるトレイトを実装しているときだけ実装を切り替えるということができる。(実際にはトレイト実装か固有実装を経由しないと特殊化できないので、もう少しコードを書く必要がある)

RFC0769時点では、このパラメトリシティを用いて「マーカートレイトのみを持つ型パラメーター」には自動的に #[may_dangle] が仮定される仕様だった。

RFC0769より前はどうだったか

RFC0769より前は、drop checkerはなかった。つまり現在でいうところの user_drop_lifetimes(T) = {} が仮定されていた。

これを保証するため、ジェネリクスパラメーターを持つ Drop は全てunsafeで、 #[unsafe_destructor] が必要だった。これがRFC0769の "Sound Generic Drop" という名称の由来である。

まとめ

Drop Checkerの規則について、 #[may_dangle] の意味を含めてまとめてみた。Drop で変な処理をするunsafeなコードを書くときはこの点を理解しておくとunsoundnessを踏み抜きにくくなると思われる。