Rust の the book の17.3の演習の解答例とBoxが mutable reference であること

めちゃくちゃ初歩的な話だと思うんですが最近改めて理解したことがあるので記事にします。

Rust の公式解説書な存在である The Rust Programming Language のチャプター17の3章には途中に演習問題が用意されています。

その章では簡単なブログ投稿ロジックもどきを作成するのですが、一通り作った後に機能追加の問題めいたものが出題されます。

https://doc.rust-lang.org/stable/book/ch17-03-oo-design-patterns.html#trade-offs-of-the-state-pattern

  • Add a reject method that changes the post’s state from PendingReview back to Draft.
  • Require two calls to approve before the state can be changed to Published.
  • Allow users to add text content only when a post is in the Draft state. Hint: have the state object responsible for what might change about the content but not responsible for modifying the Post.
  • 記事の状態をPendingReviewからDraftに戻すrejectメソッドを追加する。
  • 状態がPublishedに変化させられる前にapproveを2回呼び出す必要があるようにする。
  • 記事がDraft状態の時のみテキスト内容をユーザが追加できるようにする。 ヒント: ステートオブジェクトに内容について変わる可能性のあるものの責任を持たせつつも、 Postを変更することには責任を持たせない。
翻訳は非公式日本語訳版より https://doc.rust-jp.rs/book-ja/ch17-03-oo-design-patterns.html#%E3%82%B9%E3%83%86%E3%83%BC%E3%83%88%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3%E3%81%AE%E4%BB%A3%E5%84%9F

この問題について本の中では具体的な正解が書かれていません。なのでフォーラムでも話題になっていたようです。

https://users.rust-lang.org/t/asking-for-help-with-chapter-17-3-in-the-book/48295

最初の2つの機能追加は割と簡単なのですが、3つ目を実装しようとするとハマる人はハマるようです(私もそうです)。

ヒントに「 have the state object responsible for what might change about the content but not responsible for modifying the <code>Post.」とあるのでcontentだけを変更してPostのstate自体は変えないようにすれば良いのかなと思い、最初に以下のように書いてみました。

impl Post {
    // snip
    pub fn add_text(&mut self, text: &str) {
        let text = self.state.as_ref().unwrap().add_text(text);
        self.content.push_str(text);
    }
}

trait State: std::fmt::Debug {
    // snip
    fn add_text(self: Box<Self>, text: &str) -> &str {
        ""
    }
}

// Draft のときだけ content に文字列を追加する
impl State for Draft {
    // snip
    fn add_text(self: Box<Self>, text: &str) -> &str {
        text
    }
}

しかし、これだと以下のようなコンパイルエラーが出ます。

error[E0507]: cannot move out of a shared reference
  --> src/lib.rs:15:20
   |
15 |         let text = self.state.as_ref().unwrap().add_text(text);
   |                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^--------------
   |                    |                            |
   |                    |                            value moved due to this method call
   |                    move occurs because value has type `Box<dyn State>`, which does not implement the `Copy` trait
   |
note: this function takes ownership of the receiver `self`, which moves value
  --> src/lib.rs:52:17
   |
52 |     fn add_text(self: Box<Self>, text: &str) -> &str {
   |                 ^^^^

For more information about this error, try `rustc --explain E0507`.

Post のほうの add_text で state のメソッドを呼ぶことはできないらしいということで、色々悩んだ結果以下のように修正すると動くことは動きました。

impl Post {
    // snip
    pub fn add_text(&mut self, text: &str) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.add_text(self, text));
        }
    }
}

trait State: std::fmt::Debug {
    // snip
    fn add_text(self: Box<Self>, post: &mut Post, text: &str) -> Box<dyn State>;
}

impl State for Draft {
    // snip
    fn add_text(self: Box<Self>, post: &mut Post, text: &str) -> Box<dyn State> {
        post.content.push_str(text);
        self
    }
}

impl State for PendingReview {
    // snip
    fn add_text(self: Box<Self>, post: &mut Post, text: &str) -> Box<dyn State> {
        self
    }
}

impl State for Published {
    // snip
    fn add_text(self: Box<Self>, post: &mut Post, text: &str) -> Box<dyn State> {
        self
    }
}

しかしこれだと「not responsible for modifying the <code>Post」を満たしていないような気がします。add_text で state を変更する必要は全く無いにも関わらず、self.state.take() をしてわざわざ state を一度 None に変えて代入し直しています。その上 dyn State は何が入ってくるか実行時までわからないので安全性のために State を実装している Struct は Trait 側のデフォルト実装ではなく必ず自分自身の具体的な add_text を実装しておく必要があります。

色々とコードをいじってみた結果、最初の cannot move out of a shared reference がなんで発生していたのかわかりました。初歩的な話なのですが、State 側の add_text の引数 self が & 無しになっているからです。この書き方だと、self 自体を他のインスタンスに作り変えてしまうメソッドであるべきということになります。

Having a method that takes ownership of the instance by using just self as the first parameter is rare; this technique is usually used when the method transforms self into something else and you want to prevent the caller from using the original instance after the transformation.

https://doc.rust-lang.org/stable/book/ch05-03-method-syntax.html#defining-methods

そのため、必然的に所有権はそのメソッドに移動してしまうということのようです。

修正の方針としては、State 側の add_text 呼び出し時に自身の mutable reference を使わないようにすれば良いということになります。

impl Post {
    // snip
    pub fn add_text(&mut self, text: &str) {
        let text = self.state.as_ref().unwrap().add_text(text);
        self.content.push_str(text);
    }
}

trait State: std::fmt::Debug {
    // snip

    // 参照の引数が2つなのでスコープを付ける必要がある
    fn add_text<'a>(&self, _text: &'a str) -> &'a str {
        ""
    }
}

impl State for Draft {
    // snip
    fn add_text<'a>(&self, text: &'a str) -> &'a str {
        text
    }
}

これで元々やりたかったことができたし、コードの重複も減らせました。

色々とコードを弄りながら調べていくうちに、他にも気づいたことがありました。Box<T> の性質についてです。Box<T> について解説している章を改めて読み直すと、Box<T> を使うのは以下のような場合だと書いてあります。

  • When you have a type whose size can’t be known at compile time and you want to use a value of that type in a context that requires an exact size
  • When you have a large amount of data and you want to transfer ownership but ensure the data won’t be copied when you do so
  • When you want to own a value and you care only that it’s a type that implements a particular trait rather than being of a specific type
https://doc.rust-lang.org/stable/book/ch15-01-box.html#using-boxt-to-point-to-data-on-the-heap

2つ目の記述に、「you want to transfer onwership」とあります。Box<T>はスタックじゃなくてヒープにデータを保存してそれを参照する、という漠然とした理解をしていましたが、動きとしては take ownership な感じになるというのも覚えといたほうが良さそうです。

そもそも Box<T> や Rc<T> について概説した Smart Pointers についての記述でも以下のようにありました。

Rust, with its concept of ownership and borrowing, has an additional difference between references and smart pointers: while references only borrow data, in many cases, smart pointers own the data they point to.

https://doc.rust-lang.org/stable/book/ch15-00-smart-pointers.html#smart-pointers

このあたり難しいので何回も読み直すことになりそうです。復習は大事ですね。