オブジェクト指向の設計パターンを実装する
状態パターン は、オブジェクト指向の設計パターンです。この パターンの核心は、値が内部的に取りうる一連の状態を定義することにあります。その 状態は一連の 状態オブジェクト で表現され、値の振る舞いはその状態に応じて 変化します。ここでは、状態を保持するフィールドを持つブログ記事の構造体の例を 扱います。この状態は、「下書き」「レビュー」「公開済み」という一連の状態オブジェクトの いずれかになります。
状態オブジェクトは機能を共有します。もちろん Rust では、オブジェクトと継承ではなく、 構造体とトレイトを使います。各状態オブジェクトは、自身の振る舞いと、 いつ別の状態に変化すべきかを管理する責任を持ちます。状態オブジェクトを保持する 値は、状態ごとの異なる振る舞いや、いつ状態間を遷移すべきかについては 何も知りません。
状態パターンを使う利点は、プログラムのビジネス要件が変わったときにも、 状態を保持する値のコードや、その値を使うコードを変更する必要が ないことです。ルールを変更したり、場合によってはさらに状態オブジェクトを追加したり するには、状態オブジェクトのうち 1 つの内部コードを更新するだけで済みます。
まずは、より伝統的なオブジェクト指向のやり方で状態パターンを実装します。 その後、Rust ではもう少し自然なアプローチを使います。状態パターンを用いた ブログ記事のワークフローを、段階的に実装しながら見ていきましょう。
最終的な機能は次のようになります。
- ブログ記事は、空の下書きとして始まります。
- 下書きが完了したら、記事のレビューを依頼します。
- 記事が承認されると、公開されます。
- 公開済みのブログ記事だけが、表示するための内容を返します。これにより、未承認の記事が 誤って公開されることはありません。
記事に対してそのほかの変更を試みても、何の効果もないようにする必要があります。たとえば、 レビューを依頼する前に下書きのブログ記事を承認しようとしても、その記事は 未公開の下書きのままであるべきです。
伝統的なオブジェクト指向スタイルを試みる
同じ問題を解くためにコードを構成する方法は無数にあり、それぞれに 異なるトレードオフがあります。この節の実装は、より伝統的な オブジェクト指向スタイルのもので、Rust でも書くことは可能ですが、 Rust の強みの一部を活かしてはいません。後で、同じくオブジェクト指向の 設計パターンを使いながらも、オブジェクト指向の経験を持つプログラマには あまりなじみがないかもしれない構成の、別の解法を示します。 他の言語のコードとは異なる形で Rust のコードを設計することの トレードオフを体験するために、この 2 つの解法を比較してみましょう。
リスト 18-11 は、このワークフローをコードの形で示しています。これは、
blog という名前のライブラリクレートでこれから実装する API の使用例です。blog
クレートはまだ実装していないため、これはまだコンパイルできません。
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
ユーザーが Post::new で新しい下書きのブログ記事を作成できるようにしたいと考えています。
また、ブログ記事にテキストを追加できるようにもしたいです。承認前に
すぐ記事の内容を取得しようとしても、記事はまだ下書きなので、
テキストは何も得られないはずです。説明のために、コードには
assert_eq! を追加しています。これに対する優れたユニットテストは、
下書きのブログ記事が content メソッドから空文字列を返すことを
アサートすることですが、この例ではテストは書きません。
次に、記事のレビューを依頼できるようにしたいと考えています。そして、
レビュー待ちの間は content が空文字列を返すようにしたいです。記事が
承認を受けたら公開されるべきであり、その場合には content が
呼び出されたときに記事のテキストが返されます。
注目してほしいのは、このクレートからやり取りする型が Post
型だけだということです。この型は状態パターンを使用し、記事が取りうる
さまざまな状態、つまり下書き、レビュー、公開済みを表す 3 つの状態オブジェクトの
いずれかである値を保持します。ある状態から別の状態への変更は、
Post 型の内部で管理されます。状態は、ライブラリのユーザーが
Post インスタンスに対して呼び出すメソッドに応じて変化しますが、
ユーザー自身が直接状態遷移を管理する必要はありません。また、ユーザーが
レビュー前に記事を公開してしまう、といった状態に関する誤りを犯すこともできません。
Post を定義し、新しいインスタンスを作成する
ライブラリの実装を始めましょう。まず、何らかの内容を保持する公開の
Post 構造体が必要だとわかっています。そこで、リスト 18-12 に示すように、
構造体の定義と、Post のインスタンスを作成する関連する公開関数 new から始めます。
また、Post のすべての状態オブジェクトが備えるべき振る舞いを定義する、
非公開の State トレイトも作成します。
その後、Post は state という名前の非公開フィールドに、状態オブジェクトを保持するため
Option<T> の中に Box<dyn State> のトレイトオブジェクトを持ちます。
Option<T> が必要な理由は、少しするとわかります。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
}
trait State {}
struct Draft {}
impl State for Draft {}
State トレイトは、異なる記事の状態が共有する振る舞いを定義します。状態オブジェクトは
Draft、PendingReview、Published で、これらはすべて State
トレイトを実装します。今のところ、このトレイトにはメソッドはありません。そして、
記事が最初に入る状態がそれだから、まずは Draft 状態だけを定義することから始めます。
新しい Post を作成するとき、その state フィールドには Box を保持する
Some 値を設定します。この Box は Draft 構造体の新しいインスタンスを
指します。これにより、新しい Post インスタンスを作成するたびに、
必ず下書きとして開始されることが保証されます。Post の state フィールドは
非公開なので、ほかの状態の Post を作成する方法はありません。Post::new
関数では、content フィールドを新しい空の String に設定します。
記事内容のテキストを格納する
リスト 18-11 では、add_text という名前のメソッドを呼び出して、
&str を渡し、それがブログ記事のテキスト内容として追加されるようにしたいことが
わかりました。これを content フィールドを pub として公開するのではなく
メソッドとして実装するのは、後で content フィールドのデータが
どのように読み取られるかを制御するメソッドを実装できるようにするためです。
add_text メソッドはかなり単純なので、リスト 18-13 の実装を impl Post ブロックに追加しましょう。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
trait State {}
struct Draft {}
impl State for Draft {}
add_text メソッドが self への可変参照を受け取るのは、add_text を呼び出している Post インスタンスを変更しているからです。次に、content 内の String に対して push_str を呼び出し、text 引数を渡して保存された content に追加します。この振る舞いは、投稿がどの状態にあるかには依存しないため、ステートパターンの一部ではありません。add_text メソッドは state フィールドとまったくやり取りしませんが、サポートしたい振る舞いの一部です。
Draft 状態の Post の内容が空であることを保証する
add_text を呼び出して投稿にいくらかの内容を追加したあとでも、content メソッドには空の文字列スライスを返してほしいままです。これは、リスト 18-11 の最初の assert_eq! が示しているように、投稿がまだ draft 状態にあるためです。ひとまず、この要件を満たす最も単純な方法、つまり常に空の文字列スライスを返すように content メソッドを実装しましょう。これはあとで、投稿の状態を変更して公開できるようにする機能を実装したら変更します。ここまでのところ、投稿は draft 状態にしかなれないので、投稿内容は常に空であるべきです。リスト 18-14 はこのプレースホルダー実装を示しています。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
}
trait State {}
struct Draft {}
impl State for Draft {}
この content メソッドを追加すると、リスト 18-11 の最初の assert_eq! までのすべてが意図どおりに動作します。
レビューを依頼すると Post の状態が変化する
次に、投稿のレビューを依頼する機能を追加する必要があります。これにより、状態が Draft から PendingReview に変わるはずです。リスト 18-15 はこのコードを示しています。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
}
Post には、self への可変参照を受け取る request_review という名前の公開メソッドを追加します。次に、Post の現在の状態に対して内部の request_review メソッドを呼び出します。この 2 つ目の request_review メソッドは現在の状態を消費し、新しい状態を返します。
State トレイトに request_review メソッドを追加します。これにより、今後このトレイトを実装するすべての型は request_review メソッドを実装する必要があります。メソッドの最初の引数が self、&self、&mut self ではなく、self: Box<Self> になっていることに注目してください。この構文は、その型を保持している Box に対して呼び出された場合にのみ、このメソッドが有効であることを意味します。この構文は Box<Self> の所有権を取得し、古い状態を無効化することで、Post の状態値を新しい状態へ変換できるようにします。
古い状態を消費するために、request_review メソッドは状態値の所有権を取得する必要があります。ここで Post の state フィールドにある Option が役に立ちます。take メソッドを呼び出して state フィールドから Some の値を取り出し、その場所には None を残します。これは、Rust では構造体に値の入っていないフィールドを持たせることができないからです。これにより、Post から state の値を借用するのではなく、ムーブできるようになります。そして、その後で投稿の state の値をこの操作の結果に設定します。
state 値の所有権を取得するために、self.state = self.state.request_review(); のようなコードで直接設定するのではなく、state を一時的に None に設定する必要があります。これにより、Post は古い state 値を新しい状態に変換したあと、その古い state 値を使えないことが保証されます。
Draft に対する request_review メソッドは、レビュー待ちの状態を表す新しい PendingReview 構造体の、新しく box 化されたインスタンスを返します。PendingReview 構造体も request_review メソッドを実装しますが、変換は行いません。代わりに自分自身を返します。というのも、すでに PendingReview 状態にある投稿に対してレビューを依頼した場合でも、その投稿は PendingReview 状態のままであるべきだからです。
ここで、ステートパターンの利点が見え始めます。Post の request_review メソッドは、state の値が何であっても同じです。各状態はそれぞれ自分自身のルールに責任を持ちます。
Post の content メソッドはそのままにして、空の文字列スライスを返すようにしておきます。これで Post は Draft 状態だけでなく PendingReview 状態にもなれますが、PendingReview 状態でも同じ振る舞いを望みます。これで、リスト 18-11 は 2 つ目の assert_eq! 呼び出しまで動作するようになります。
approve を追加して content の振る舞いを変える
approve メソッドは request_review メソッドと似たものになります。リスト 18-16 に示されているように、その状態が承認されたときに現在の状態が持つべきだとする値に state を設定します。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
State トレイトに approve メソッドを追加し、State を実装する新しい構造体である Published 状態も追加します。
PendingReview に対する request_review の動作と同様に、Draft に対して approve メソッドを呼び出しても効果はありません。なぜなら、approve は self を返すからです。PendingReview に対して approve を呼び出すと、新しく box 化された Published 構造体のインスタンスを返します。Published 構造体は State トレイトを実装しており、request_review メソッドと approve メソッドの両方について、自分自身を返します。これは、その場合に投稿が Published 状態のままであるべきだからです。
ここで、Post の content メソッドを更新する必要があります。content から返される値を Post の現在の状態に依存させたいので、リスト 18-17 に示すように、Post が自分の state に定義された content メソッドへ処理を委譲するようにします。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --snip--
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
これらのルールをすべて State を実装する構造体の内部に保つことが目標なので、state 内の値に対して content メソッドを呼び出し、投稿インスタンス(つまり self)を引数として渡します。次に、state 値の content メソッドを使って返された値を返します。
Option の as_ref メソッドを呼び出すのは、値の所有権ではなく、Option の内側にある値への参照が欲しいからです。state は Option<Box<dyn State>> なので、as_ref を呼び出すと Option<&Box<dyn State>> が返されます。as_ref を呼び出さなければ、関数パラメータの借用された &self から state をムーブすることはできないため、エラーになります。
次に unwrap メソッドを呼び出します。これが panic しないことは確実です。なぜなら、Post のメソッドは、それらの処理が終わる時点で state が常に Some の値を含むことを保証していると分かっているからです。これは第9章の “When You Have More Information Than the Compiler” 節で説明したケースの1つで、コンパイラには理解できなくても、None の値が決してありえないと分かっている場合です。
この時点で、&Box<dyn State> に対して content を呼び出すと、& と Box に対してデリファレンス強制が働き、最終的に State トレイトを実装している型に対して content メソッドが呼び出されます。つまり、State トレイト定義に content を追加する必要があり、どの状態にあるかに応じてどの内容を返すかというロジックをそこに置くことになります。これはリスト18-18に示されています。
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
// --snip--
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
空の文字列スライスを返す content メソッドのデフォルト実装を追加します。つまり、Draft 構造体と PendingReview 構造体では content を実装する必要はありません。Published 構造体は content メソッドをオーバーライドして、post.content の値を返します。便利ではあるものの、Post の内容を State 上の content メソッドで決定するのは、State の責務と Post の責務の境界を曖昧にしています。
このメソッドにはライフタイム注釈が必要であることに注意してください。これは第10章で説明しました。引数として post への参照を受け取り、その post の一部への参照を返しているので、返される参照のライフタイムは post 引数のライフタイムに関連付けられます。
これで完了です。リスト18-11全体が動作するようになりました。ブログ記事のワークフローのルールとともに、状態パターンを実装できました。ルールに関するロジックは、Post 全体に散らばるのではなく、状態オブジェクトの中に存在します。
なぜ enum ではないのか?
なぜ異なる記事状態をバリアントとして持つ enum を使わなかったのか、不思議に思っていたかもしれません。もちろん、それも可能な解決策です。試してみて、どちらが好みか最終的な結果を比較してみてください! enum を使う欠点の1つは、enum の値をチェックするあらゆる場所で、すべての可能なバリアントを扱うために
match式、またはそれに類するものが必要になることです。これは、このトレイトオブジェクトによる解決策よりも繰り返しが多くなる可能性があります。
状態パターンの評価
Rust が、各状態において記事が持つべきさまざまな種類の振る舞いをカプセル化するために、オブジェクト指向の状態パターンを実装できることを示しました。Post 上のメソッドは、さまざまな振る舞いについて何も知りません。コードの構成方法のおかげで、公開済みの記事がどのように振る舞うかという異なる方法を知るために見る必要がある場所は1か所だけです。つまり、Published 構造体に対する State トレイトの実装です。
状態パターンを使わない別の実装を作るとしたら、代わりに Post 上のメソッド、あるいは記事の状態を確認してその場所で振る舞いを変える main コードの中で match 式を使うかもしれません。そうなると、記事が公開済み状態にあることのすべての含意を理解するために、複数の場所を見る必要が出てきます。
状態パターンでは、Post のメソッドや Post を使う場所では match 式は不要であり、新しい状態を追加するには、新しい構造体を追加して、その1つの構造体に対して1か所でトレイトメソッドを実装するだけで済みます。
状態パターンを使った実装は、さらに多くの機能を追加するために拡張しやすくなっています。状態パターンを使うコードの保守がどれほど単純かを確認するために、次の提案をいくつか試してみてください。
- 記事の状態を
PendingReviewからDraftに戻すrejectメソッドを追加する。 - 状態を
Publishedに変更できるようになる前に、approveを2回呼び出すことを必須にする。 - 記事が
Draft状態にあるときだけ、ユーザーがテキスト内容を追加できるようにする。 ヒント: 内容について何が変化しうるかは状態オブジェクトの責務としつつ、Postを変更する責務は持たせないようにします。
状態パターンの欠点の1つは、状態が状態間の遷移を実装しているため、状態同士が互いに結び付いてしまうことです。PendingReview と Published の間に Scheduled のような別の状態を追加すると、PendingReview のコードを変更して Scheduled へ遷移するようにしなければなりません。新しい状態の追加に伴って PendingReview を変更しなくて済めば作業は少なくなりますが、それは別のデザインパターンへ切り替えることを意味します。
もう1つの欠点は、いくつかのロジックを重複していることです。この重複をなくすために、State トレイト上の request_review および approve メソッドに、self を返すデフォルト実装を作ろうとするかもしれません。しかし、これはうまくいきません。State をトレイトオブジェクトとして使う場合、そのトレイトは具象的な self が正確には何になるかを知りません。そのため、戻り値の型はコンパイル時に分かりません。(これは先ほど触れた dyn 互換性ルールの1つです。)
そのほかの重複としては、Post 上の request_review メソッドと approve メソッドの実装が似ていることが挙げられます。どちらのメソッドも、Post の state フィールドに対して Option::take を使い、state が Some であれば、ラップされた値の同名メソッドの実装に委譲し、その結果を state フィールドの新しい値として設定します。このパターンに従うメソッドが Post に多数あるなら、繰り返しをなくすためにマクロの定義を検討するかもしれません(第20章の “Macros” 節を参照してください)。
状態パターンをオブジェクト指向言語向けに定義されているとおりに正確に実装することで、Rust の強みを十分には活かせていません。blog クレートに加えられるいくつかの変更を見て、無効な状態や遷移をコンパイル時エラーにできるようにしてみましょう。
状態と振る舞いを型としてエンコードする
異なるトレードオフの組を得るために、状態パターンをどう考え直せるかを示します。状態と遷移を完全にカプセル化して、外部のコードがそれらをまったく知らないようにする代わりに、状態を異なる型にエンコードします。その結果、Rust の型チェックシステムは、公開済み記事だけが許可される場所で下書きの記事を使おうとする試みを、コンパイラエラーを出すことで防いでくれます。
リスト18-11の main の最初の部分を考えてみましょう:
```rust,ignore
# use blog::Post;
#
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
#
# post.request_review();
# assert_eq!("", post.content());
#
# post.approve();
# assert_eq!("I ate a salad for lunch today", post.content());
}
Post::new を使って下書き状態の新しい投稿を作成できることと、投稿の内容にテキストを追加できることは、引き続き有効にします。しかし、空文字列を返す下書き投稿の content メソッドを用意する代わりに、下書き投稿には content メソッド自体を持たせないようにします。そうすれば、下書き投稿の内容を取得しようとしたとき、メソッドが存在しないことを示すコンパイルエラーが発生します。その結果、本番環境で誤って下書き投稿の内容を表示してしまうことは不可能になります。なぜなら、そのコードはそもそもコンパイルされないからです。リスト18-19は、Post 構造体と DraftPost 構造体の定義、およびそれぞれのメソッドを示しています。
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
Post 構造体と DraftPost 構造体はどちらも、ブログ投稿のテキストを保存する非公開の content フィールドを持っています。構造体はもはや state フィールドを持っていません。これは、状態の表現を構造体の型へ移すためです。Post 構造体は公開済みの投稿を表し、content を返す content メソッドを持っています。
Post::new 関数は引き続きありますが、Post のインスタンスを返す代わりに、DraftPost のインスタンスを返します。content は非公開であり、Post を返す関数も存在しないため、現時点では Post のインスタンスを作成することはできません。
DraftPost 構造体には add_text メソッドがあるので、以前と同じように content にテキストを追加できます。ただし、DraftPost には content メソッドが定義されていないことに注意してください。これにより、すべての投稿は下書き投稿として始まり、下書き投稿の内容は表示のために利用できないことがプログラムによって保証されます。こうした制約を回避しようとする試みは、どれもコンパイルエラーになります。
では、どうすれば公開済みの投稿を得られるのでしょうか。私たちは、下書き投稿は公開される前にレビューと承認を受けなければならない、というルールを強制したいと考えています。レビュー待ち状態の投稿も、やはり内容を表示してはいけません。これらの制約を実装するために、別の構造体 PendingReviewPost を追加し、DraftPost に PendingReviewPost を返す request_review メソッドを定義し、PendingReviewPost に Post を返す approve メソッドを定義します。これはリスト18-20に示されています。
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
// --snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn request_review(self) -> PendingReviewPost {
PendingReviewPost {
content: self.content,
}
}
}
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> Post {
Post {
content: self.content,
}
}
}
request_review メソッドと approve メソッドは self の所有権を受け取るため、DraftPost と PendingReviewPost のインスタンスを消費し、それぞれ PendingReviewPost と公開済みの Post へと変換します。この方法により、それらに対して request_review を呼び出したあとに DraftPost インスタンスが残ることはありません。PendingReviewPost 構造体にも content メソッドは定義されていないため、DraftPost と同様に、その内容を読もうとするとコンパイルエラーになります。content メソッドを持つ公開済みの Post インスタンスを得る唯一の方法は、PendingReviewPost に対して approve メソッドを呼び出すことです。そして PendingReviewPost を得る唯一の方法は、DraftPost に対して request_review メソッドを呼び出すことです。これで、ブログ投稿のワークフローを型システムにエンコードしたことになります。
しかし、main にもいくつか小さな変更を加える必要があります。request_review メソッドと approve メソッドは、呼び出された構造体を変更するのではなく新しいインスタンスを返すため、返されたインスタンスを保存するために、さらに let post = によるシャドーイング代入を追加する必要があります。また、下書き投稿およびレビュー待ち投稿の内容が空文字列であることを確認するアサーションも置けなくなりますし、そもそもそれらは不要です。そうした状態の投稿の内容を使おうとするコードは、もはやコンパイルできないからです。更新後の main のコードをリスト18-21に示します。
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
let post = post.request_review();
let post = post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
main で post を再代入するために必要だった変更は、この実装がもはやオブジェクト指向の状態パターンに完全には従っていないことを意味します。状態間の変換が、もはや Post 実装の内部だけに完全にカプセル化されていないからです。しかし、その代わりに得られるのは、型システムとコンパイル時に行われる型チェックによって、不正な状態が不可能になることです。これにより、未公開の投稿内容を表示してしまうといった特定のバグは、本番環境に到達する前に発見されることが保証されます。
この節の冒頭で提案した課題を、リスト18-21後の blog クレートに対して試してみて、このバージョンのコードの設計についてどう思うか確かめてみてください。この設計では、いくつかの課題はすでに満たされているかもしれないことに注意してください。
Rust はオブジェクト指向の設計パターンを実装できますが、状態を型システムにエンコードするような別のパターンも Rust では利用できることを見てきました。これらのパターンには、それぞれ異なるトレードオフがあります。あなたはオブジェクト指向パターンに非常に慣れているかもしれませんが、Rust の機能を活用するように問題を捉え直すことで、コンパイル時に一部のバグを防げるなどの利点が得られます。所有権のように、オブジェクト指向言語にはない特定の機能があるため、オブジェクト指向パターンが Rust において常に最良の解決策になるとは限りません。
まとめ
この章を読んだあとで Rust がオブジェクト指向言語かどうかをどう考えるにせよ、trait object を使って Rust でいくつかのオブジェクト指向的機能を得られることは、もう分かったはずです。動的ディスパッチは、少しの実行時性能と引き換えに、コードにある程度の柔軟性を与えることができます。この柔軟性を使えば、コードの保守性に役立つオブジェクト指向パターンを実装できます。Rust には、オブジェクト指向言語にはない所有権のような他の機能もあります。オブジェクト指向パターンは、Rust の強みを活かすための最善の方法とは限りませんが、利用可能な選択肢のひとつです。
次は、Rust のもうひとつの機能であり、大きな柔軟性を可能にするパターンについて見ていきます。これまでも本書を通して少し触れてきましたが、その能力をまだ十分には見ていません。さあ、始めましょう!