June 18, 2020

English | 日本語

Rust Pin trait (日本語)

Rust の Pin trait はマニュアルが分かりにくいと思ったので少し書いてみる。

結論から言うと以下のようになる。

  1. 普通は object を move しても問題ないが、例外もある。例えば自己参照を持つ場合などだ。

  2. Rust の型システムでは move を禁止することは出来ない。

  3. Rust には move を防ぐためのパターンが 2 個ある。どちらのパターンでもポインターの様な変数 (参照など) を使う。

  4. std::mem::swap() など一部の関数は文法としての move を使うことは無いが同じ効果を持つ。

  5. ポインターの様な変数を Pin 型でラップすれば、文法としての move だけではなく上記のような関数に関するバグもコンパイラーで検知できるようになる。

自己参照型の例

最初になぜ自己参照型のオブジェクトを move してはいけないのか確認しておく。

struct SelfRef {
    x: i32,
    ptr_x: *const i32,  // x を指すポインター
}

impl SelfRef {
    /// コンストラクターだが、問題がある
    pub fn new(x: i32) -> Self {
        let mut this = Self {
            x,
            ptr_x: std::ptr::null(),
         };
         this.ptr_x = &this.x;

         // このチェックは問題なく通る
         this.check();

         // 戻り値を返す時に move している
         // ここで問題が発生
         this
    }

    /// ptr_x が変数 x を指しているか確認する
    pub fn check(&self) {
        assert!(&self.x as *const i32 == self.ptr_x);
    }
}

fn main() {
    let obj = SelfRef::new(5);

    // ここで assertion error が発生する。
    // obj 自身は move されているが obj.ptr_x は
    // move 前に obj.x があった場所を指し続けているため
    obj.check();
}

SelfRef::ptr_x は自身の変数 x を指しているはず。つまり自己参照型だ。 ここが問題となる。

最後の obj.check() はエラーとなってしまう。 なぜなら obj は初期化後に move されているから。 当然 obj.x も move と同時に移動するが、obj.ptr_x は依然として obj.x が初期化された時にあった場所を指し続ける。

どうやって move を防ぐか?

SelfRef を move すると、どうやっても問題が発生する。

そのため「move はしない」という前提の元で「うっかり move してしまう事によるバグをどうやって回避するか?」にフォーカスする事になる。

これには以下の 2 種類の方法がある。

  1. オブジェクトを heap に作ることで move を回避する。

  2. オブジェクトを stack に作り、もし move したらコンパイラーがバグを見つけられるようにする。

オブジェクトを heap に作る

この方法はコンストラクターの戻り値を変えるだけでうまく行く

... これ以前は前例と同じなので省略

impl SelfRef {
    /// コンストラクターだが、戻り値の型が
    /// SelfRef ではなく Box<SelfRef> になっている
    pub fn new(x: i32) -> Box<Self> {
        let mut this = Box::new(SelfRef {
            x,
            ptr_x: std::ptr::null(),
        });
        this.ptr_x = &this.x;

        this.check();

        // 返すのは「オブジェクトへのポインター」であり
        // オブジェクトの場所は変わらない (move されない)
        this
    }

 ... 以降は前例と同じなので省略

コンストラクターは heap 上にメモリーを確保し、そこにオブジェクトを作る。 戻り値はオブジェクトへのポインターなので、オブジェクト自身の移動は発生しない。

この方法は上手く行くが、heap メモリーを確保するのでパフォーマンスはあまり良くない。

コンパイラーにバグを検知できるようにする

2 番目の方法はオブジェクトを stack 上に構築し、すぐにその変数を隠してしまうものだ。

変数が無いので move する事もできなくなる。

具体的には次のようにする。 (実際にはコンパイラーがバグを検知するので、コンパイルできない。)

... これ以前は前例と同じなので省略

impl SelfRef {
    /// コンパイルエラーを起こす
    pub fn new(x: i32) -> Self {
        let mut this = Self {
            x,
            ptr_x: std::ptr::null(),
         };

         // 変数 this を上書きすることで、元の変数を隠す。
         // しかし、オブジェクト自身は stack に残り続ける。
         //
         // 新しい "this" はオブジェクトへの参照なので
         // オブジェクトへのアクセスは可能。
         let this = &mut this;

         this.ptr_x = &this.x;

         this.check();

         // 関数の戻り値は SelfRef だが
         // "this" の型は &mut SelfRef である。
         // そのため、コンパイルできない
         this
    }

 ... 以降は前例と同じなので省略

このコンストラクターの戻り値は SelfRef 型だが、そのような型の変数は無い。("this" の型は &mut SelfRef。)

もちろん、SelfRef に Copy trait を実装して最後に this ではなく *this を返せばコンパイルは通る。 しかし、一般的に move できない型は copy もするべきでは無い。 この方法はそれ以前の問題だ。

Pin trait

上で紹介した 2 種類の方法は move される事を防いでくれる。 しかし std::mem::swap() のような関数は move はしていないが実際には同じ結果をもたらす。 この場合、依然としてバグは発生してしまう。

下記の例は 2 番目 (コンストラクターが Box<SelfRef> を返す例) とほとんど同じだが、main() の中で std::mem::swap() を呼んでいる。

struct SelfRef {
    x: i32,
    ptr_x: *const i32,  //  x を指すポインター
}

impl SelfRef {
    /// コンストラクター
    pub fn new(x: i32) -> Box<Self> {
        let mut this = Box::new(Self {
            x,
            ptr_x: std::ptr::null(),
         });
         this.ptr_x = &this.x;

         this.check();

         this
    }

    pub fn check(&self) {
        assert!(&self.x as *const i32 == self.ptr_x);
    }
}

fn main() {
    // obj1 の構築。
    let mut obj1 = SelfRef::new(1);
    obj1.check();

    // obj2 の構築。
    let mut obj2 = SelfRef::new(2);
    obj2.check();

    // obj1 と obj2 をスワップする
    // 文法上は move していないが、move と同じ効果を持つ
    std::mem::swap(&mut *obj1, &mut *obj2);

    // 実質的に move しているのでエラー
    obj1.check();
}

最後の obj.check() は直前の std::mem::swap() が move と同じ働きをするのでエラーとなる。

こんな時は Pin trait の出番だ。

main() 関数を以下のように変更してみる

... これ以前は前例と同じなので省略

// Pin trait を使用
// (ただし、これだけでは効果が無い)
fn main() {
    // std::pin::Pin::new_unchecked() が unsafe なので
    // unsafe {} でくくっている。
    unsafe {
        // obj1 と obj2 の構築
        let obj1 = SelfRef::new(1);
        obj1.check();

        let obj2 = SelfRef::new(2);
        obj2.check();

        // obj1 と obj2 を Pin でラップする
        let mut obj1 = std::pin::Pin::new_unchecked(obj1);
        let mut obj2 = std::pin::Pin::new_unchecked(obj2);

        std::mem::swap(&mut *obj1, &mut *obj2);

        // 相変わらずエラー発生
        obj1.check();
    }
}

最後の obj1.check() では相変わらずエラーとなる。

実は Pin trait は Unpin trait を実装している型のポインターをラップしても何もしないのだ。

SelfRef は Unpin を実装しているのだろうか?

実際には実装している。

Rust のコンパイラーは全てのプロパティーが Unpin である型に対して自動的に Unpin を実装する。 実際に move を禁止する型など少ないし、極めて自然だ。

そのため、今回は明示的に Unpin の実装を止める必要がある。

std::marker::PhantomPinned は Unpin を実装していない phantom data だ。 (Phantom data とは Rust ではサイズが 0 byte の型.)

これを SelfRef に加えてみる (main 関数には変更は無い)

struct SelfRef {
    x: i32,
    ptr_x: *const i32,  //  x を指すポインター
    _phantom: std::marker::PhantomPinned,
}

impl SelfRef {
    pub fn new(x: i32) -> Box<Self> {
        let mut this = Box::new(Self {
            x,
            ptr_x: std::ptr::null(),
            _phantom: std::marker::PhantomPinned,
         });
         this.ptr_x = &this.x;

         this.check();

         this
    }

    pub fn check(&self) {
        assert!(&self.x as *const i32 == self.ptr_x);
    }
}

fn main() {
    unsafe {
        // obj1 と obj2 の構築
        let obj1 = SelfRef::new(1);
        obj1.check();

        let obj2 = SelfRef::new(2);
        obj2.check();

        // obj1 と obj2 を Pin でラップする
        // これにより、obj1 や obj2 の可変なポインターを
        // 取得できなくなる
        let mut obj1 = std::pin::Pin::new_unchecked(obj1);
        let mut obj2 = std::pin::Pin::new_unchecked(obj2);

        // `&mut *obj1` の取得が出来ないので
        // コンパイルエラー
        std::mem::swap(&mut *obj1, &mut *obj2);

        obj1.check();
    }
}

これで問題が解決。 コンパイラーがバグを検知できるようになった。

結論

C++ 等と異なり Rust では move 不可能な型を定義できない。

代わりに Rust には 2 種類のビルドパターンが存在する。

  • heap 上にメモリを確保しオブジェクトをそこで構築する。このオブジェクトは move されなくなる。

  • stack 上にオブジェクトを構築し、その変数をオブジェクトへの参照で上書きする。オブジェクトを表す変数が無いので move しようとすると型エラーとなりコンパイラーがバグを検知できるようになる。

いずれの方法でもプログラマーはポインターのような変数を通じてオブジェクトへアクセスする。 このポインターのような変数を Pin でラップすると、std::mem::swap() のような move と同じ働きをする関数もコンパイルエラーで検知できるようになる。

ただ、この仕組みって本当に良いか? まあ色々と理由はあるんだろうけど、複雑すぎないだろうか? 例えば Unpin trait とか、本当に必要だったんだろうか?

何よりオブジェクトを構築時にこのパターンを使う事を忘れたら、結局バグを防ぐ事はできないじゃないか。 (マクロとかで対応できるかもしれないけど、もっと複雑になる。)

好きになれないなぁ。