オブジェクト指向デザインパターンを実装する
ステートパターンは、オブジェクト指向デザインパターンの1つです。 このパターンの肝は、値が内部的に持つことができる状態の集合を定義するということです。 状態はステートオブジェクトの集合として表現され、その状態に基づいて値の振る舞いが変化します。 ここでは、自身の状態を保持するフィールドを持つ、ブログ記事構造体の例に取り組んでいきます。 この構造体のフィールドは「草稿」、「査読」、「公開済み」からなる集合のうちのいずれかの状態オブジェクトになるでしょう。
ステートオブジェクトは機能を共有します: Rustでは、もちろん、オブジェクトと継承ではなく、構造体とトレイトを使用します。 各ステートオブジェクトは、自身の振る舞いと別の状態に変化すべき時を司ることに責任を持ちます。 ステートオブジェクトを保持する値は、状態ごとの異なる振る舞いや、いつ状態が移行するかについては何も知りません。
ステートパターンを使用することの利点は、プログラムの業務要件が変わる時、状態を保持する値のコードや、 値を使用するコードを変更する必要がない点です。ステートオブジェクトの1つのコードを更新して、 規則を変更したり、あるいはおそらくステートオブジェクトを追加する必要しかないのです。
まずは、より伝統的なオブジェクト指向の手法でステートパターンを実装し、 その後、Rustとしてもう少し自然なアプローチを使用します。 それでは、ステートパターンを利用したブログ記事のワークフローの漸進的な実装に取り組んでいきましょう。
最終的な機能は以下のような感じになるでしょう:
- ブログ記事は、空の草稿から始まる。
- 草稿ができたら、査読が要求される。
- 記事が承認されたら、公開される。
- 公開されたブログ記事だけが表示する内容を返すので、未承認の記事は、誤って公開されない。
それ以外の記事に対する変更は、効果を持つべきではありません。例えば、査読を要求する前にブログ記事の草稿を承認しようとしたら、 記事は、非公開の草稿のままになるべきです。
リスト17-11は、このワークフローをコードの形で示しています: これは、
blogというライブラリクレートに実装するAPIの使用例です。blogクレートを実装していないので、
コンパイルはできません。
ファイル名: src/main.rs
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());
}
リスト17-11: blogクレートに欲しい振る舞いをデモするコード
ユーザがPost::newで新しいブログ記事の草稿を作成できるようにしたいです。
ブログ記事にテキストを追加できるようにしたいです。承認前に記事の内容を即座に得ようとしたら、
記事はまだ草稿なので、何のテキストも得られるべきではありません。デモ目的でコードにassert_eq!を追加しました。
これに対する素晴らしい単体テストは、ブログ記事の草稿がcontentメソッドから空の文字列を返すことをアサートすることでしょうが、
この例に対してテストを書くつもりはありません。
次に、記事の査読を要求できるようにしたく、また査読を待機している間はcontentに空の文字列を返してほしいです。
記事が承認を受けたら、公開されるべきです。つまり、contentを呼んだ時に記事のテキストが返されるということです。
クレートから相互作用している唯一の型は、Postだけであることに注意してください。
この型はステートパターンを使用し、記事がなり得る種々の状態を表す3つのステートオブジェクトのうちの1つになる値を保持します。
草稿、査読待ち、公開中です。1つの状態から別の状態への変更は、Post型内部で管理されます。
Postインスタンスのライブラリ使用者が呼び出すメソッドに呼応して状態は変化しますが、
状態の変化を直接管理する必要はありません。また、ユーザは、
査読前に記事を公開するなど状態を誤ることはありません。
Postを定義し、草稿状態で新しいインスタンスを生成する
ライブラリの実装に取り掛かりましょう!なんらかの内容を保持する公開のPost構造体が必要なことはわかるので、
構造体の定義と、関連する公開のPostインスタンスを生成するnew関数から始めましょう。リスト17-12のようにですね。
また、Postのすべての状態オブジェクトが持たなくてはならない振る舞いを定義する、
非公開のStateトレイトも作成します。
それからPostは、状態オブジェクトを保持するためのstateという非公開のフィールドに、
Option<T>でBox<dyn State>のトレイトオブジェクトを保持します。Option<T>が必要な理由はすぐわかります。
ファイル名: src/lib.rs
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 {}
リスト17-12: Post構造体、新規Postインスタンスを生成するnew関数、
Stateトレイト、Draft構造体の定義
Stateトレイトは、異なる記事の状態で共有される振る舞いを定義します。状態オブジェクトはDraft、PendingReview、Publishedで、
これらはすべてStateトレイトを実装します。今は、トレイトにメソッドは何もなく、Draftが記事の初期状態にしたい状態なので、
その状態だけを定義することから始めます。
新しいPostを作る時、stateフィールドは、Boxを保持するSome値にセットします。
このBoxがDraft構造体の新しいインスタンスを指します。これにより、
新しいPostを作る度に、草稿から始まることが保証されます。Postのstateフィールドは非公開なので、
Postを他の状態で作成する方法はないのです!Post::new関数では、contentフィールドを新しい空のStringにセットしています。
記事の内容のテキストを格納する
リスト17-11で、add_textというメソッドを呼び出し、ブログ記事のテキスト内容として追加される&strを渡せるようになりたいことを確認しました。
後ほどcontentフィールドデータの読まれ方を制御するメソッドを実装できるように、
contentフィールドをpubにして晒すのではなく、これをメソッドとして実装しています。
add_textメソッドは非常に素直なので、リスト17-13の実装をimpl Postブロックに追加しましょう:
ファイル名: src/lib.rs
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 {}
リスト17-13: 記事のcontentにテキストを追加するadd_textメソッドを実装する
add_textメソッドは、selfへの可変参照を取ります。というのも、add_textを呼び出したPostインスタンスを変更しているからです。
それからcontentのStringに対してpush_strを呼び出し、text引数を渡して保存されたcontentに追加しています。
この振る舞いは、記事の状態によらないので、ステートパターンの一部ではありません。add_textメソッドは、
stateフィールドと全く相互作用しませんが、サポートしたい振る舞いの一部ではあります。
草稿の記事の内容は空であることを保証する
add_textを呼び出して記事に内容を追加した後でさえ、記事はまだ草稿状態なので、
それでもcontentメソッドには空の文字列スライスを返してほしいです。
リスト17-11の7行目で示したようにですね。とりあえず、この要求を実現する最も単純な方法でcontentメソッドを実装しましょう:
常に空の文字列スライスを返すことです。一旦、記事の状態を変更する能力を実装したら、公開できるように、
これを後ほど変更します。ここまで、記事は草稿状態にしかなり得ないので、記事の内容は常に空のはずです。
リスト17-14は、この仮の実装を表示しています:
ファイル名: src/lib.rs
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 {}
リスト17-14: Postに常に空の文字列スライスを返すcontentの仮の実装を追加する
この追加されたcontentメソッドとともに、リスト17-11の7行目までのコードは、想定通り動きます。
記事の査読を要求すると、状態が変化する
次に、記事の査読を要求する機能を追加する必要があり、これをすると、状態がDraftからPendingReviewに変わるはずです。
リスト17-15はこのコードを示しています:
ファイル名: src/lib.rs
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
}
}
リスト17-15: PostとStateトレイトにrequest_reviewメソッドを実装する
Postにselfへの可変参照を取るrequest_reviewという公開メソッドを与えます。それから、
Postの現在の状態に対して内部のrequest_reviewメソッドを呼び出し、
この2番目のrequest_reviewが現在の状態を消費し、新しい状態を返します。
Stateトレイトにrequest_reviewメソッドを追加します; このトレイトを実装する型は全て、
これでrequest_reviewメソッドを実装する必要があります。メソッドの第1引数にself、&self、&mut selfではなく、
self: Box<Self>としていることに注意してください。この記法は、型を保持するBoxに対して呼ばれた時のみ、
このメソッドが合法になることを意味しています。この記法は、Box<Self>の所有権を奪い、古い状態を無効化するので、
Postの状態値は、新しい状態に変形できます。
古い状態を消費するために、request_reviewメソッドは、状態値の所有権を奪う必要があります。
ここでPostのstateフィールドのOptionが問題になるのです: takeメソッドを呼び出して、
stateフィールドからSome値を取り出し、その箇所にNoneを残します。なぜなら、Rustは、
構造体に未代入のフィールドを持たせてくれないからです。これにより、借用するのではなく、
Postのstate値をムーブすることができます。それから、記事のstate値をこの処理の結果にセットするのです。
self.state = self.state.request_review();のようなコードで直接state値の所有権を得るよう設定するのではなく、
一時的にNoneにstateをセットする必要があります。これにより、新しい状態に変形した後に、
Postが古いstate値を使えないことが保証されるのです。
Draftのrequest_reviewメソッドは、新しいPendingReview構造体の新しいボックスのインスタンスを返し、
これが、記事が査読待ちの時の状態を表します。PendingReview構造体もrequest_reviewメソッドを実装しますが、
何も変形はしません。むしろ、自身を返します。というのも、既にPendingReview状態にある記事の査読を要求したら、
PendingReview状態に留まるべきだからです。
ようやくステートパターンの利点が見えてき始めました: state値が何であれ、Postのrequest_reviewメソッドは同じです。
各状態は、独自の規則にのみ責任を持ちます。
Postのcontentメソッドを空の文字列スライスを返してそのままにします。
これでPostはPendingReviewとDraft状態になり得ますが、PendingReview状態でも、
同じ振る舞いが欲しいです。もうリスト17-11は10行目まで動くようになりました!
contentの振る舞いを変化させるapproveを追加する
approveメソッドは、request_reviewメソッドと類似するでしょう: 状態が承認された時に、
現在の状態があるべきと言う値にstateをセットします。リスト17-16のようにですね:
ファイル名: src/lib.rs
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
}
}
リスト17-16: PostとStateトレイトにapproveメソッドを実装する
Stateトレイトにapproveメソッドを追加し、Published状態というStateを実装する新しい構造体を追加します。
PendingReviewに対してrequest_reviewが行っているのと同様に、
Draftに対してapproveメソッドを呼び出しても、approveはselfを返すので、
何も効果はありません。PendingReviewに対してapproveを呼び出すと、
Published構造体の新しいボックス化されたインスタンスを返します。Published構造体はStateトレイトを実装し、
request_reviewメソッドとapproveメソッド両方に対して、自身を返します。
そのような場合に記事は、Published状態に留まるべきだからです。
さて、Postのcontentメソッドを更新する必要が出てきました。
contentから返される値をPostの現在の状態に依存するようにしたいので、
Postは、そのstateに定義されたcontentメソッドに委譲するようにします。
リスト17-17のようにですね:
ファイル名: src/lib.rs
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
}
}
リスト17-17: Postのcontentメソッドを更新してStateのcontentメソッドに委譲する
目的は、これらの規則全てをStateを実装する構造体の内部に押し留めることなので、stateの値に対してcontentメソッドを呼び出し、
記事のインスタンス(要するに、self)を引数として渡します。そして、state値のcontentメソッドを使用したことから返ってきた値を返します。
Optionに対してas_refメソッドを呼び出します。値の所有権ではなく、Option内部の値への参照が欲しいからです。
stateはOption<Box<dyn State>>なので、as_refを呼び出すと、Option<&Box<dyn State>>が返ってきます。
as_refを呼ばなければ、stateを関数引数の借用した&selfからムーブできないので、エラーになるでしょう。
さらにunwrapメソッドを呼び出し、これは絶対にパニックしないことがわかっています。何故なら、
Postのメソッドが、それらのメソッドが完了した際にstateは常にSome値を含んでいることを保証するからです。
これは、コンパイラには理解不能であるものの、
None値が絶対にあり得ないとわかる第9章の「コンパイラよりもプログラマがより情報を持っている場合」節で語った一例です。
この時点で、&Box<dyn State>に対してcontentを呼び出すと、参照外し型強制が&とBoxに働くので、
究極的にcontentメソッドがStateトレイトを実装する型に対して呼び出されることになります。
つまり、contentをStateトレイト定義に追加する必要があり、そこが現在の状態に応じてどの内容を返すべきかというロジックを配置する場所です。
リスト17-18のようにですね:
ファイル名: src/lib.rs
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
}
}
リスト17-18: Stateトレイトにcontentメソッドを追加する
空の文字列スライスを返すデフォルト実装をcontentメソッドに追加しています。これにより、
DraftとPendingReview構造体にcontentを実装する必要はありません。Published構造体は、
contentメソッドをオーバーライドし、post.contentの値を返します。
第10章で議論したように、このメソッドにはライフタイム注釈が必要なことに注意してください。
postへの参照を引数として取り、そのpostの一部への参照を返しているので、
返却される参照のライフタイムは、post引数のライフタイムに関連します。
出来上がりました。要するに、リスト17-11はもう動くようになったのです!ブログ記事ワークフローの規則でステートパターンを実装しました。
その規則に関連するロジックは、Post中に散乱するのではなく、ステートオブジェクトに息づいています。
enumを使えばいいのでは?
可能な記事の状態を列挙子とする
enumを使わなかったのはなぜか、疑問に感じているかもしれません。 それももちろんあり得る解決策です。試してみて、どちらが好みか、最終結果を比較してみてください! enumを使うことの不利な点のひとつは、enumの値をチェックするすべての場所で、match式か、それに類似する、すべての可能な列挙子を処理するための方法が必要になるだろうということです。 これは、トレイトオブジェクトを使用する解決策と比較して、より煩わしくなることがあります。
ステートパターンの代償
オブジェクト指向のステートパターンを実装して各状態の時に記事がなり得る異なる種類の振る舞いをカプセル化する能力が、
Rustにあることを示してきました。Postのメソッドは、種々の振る舞いについては何も知りません。
コードを体系化する仕方によれば、公開された記事が振る舞うことのある様々な方法を知るには、1箇所のみを調べればいいのです:
Published構造体のStateトレイトの実装です。
ステートパターンを使用しない対立的な実装を作ることになったら、代わりにPostのメソッドか、
あるいは記事の状態を確認し、それらの箇所(編注: Postのメソッドのことか)の振る舞いを変更するmainコードでさえ、
match式を使用したかもしれません。そうなると、複数個所を調べて記事が公開状態にあることの裏の意味全てを理解しなければならなくなります!
これは、追加した状態が増えれば、さらに上がるだけでしょう: 各match式には、別のアームが必要になるのです。
ステートパターンでは、PostのメソッドとPostを使用する箇所で、match式が必要になることはなく、
新しい状態を追加するのにも、新しい構造体を追加し、その1つの構造体にトレイトメソッドを実装するだけでいいわけです。
ステートパターンを使用した実装は、拡張して機能を増やすことが容易です。 ステートパターンを使用するコードの管理の単純さを確認するために、以下の提言を試してみてください:
- 記事の状態を
PendingReviewからDraftに戻すrejectメソッドを追加する。 - 状態が
Publishedに変化させられる前にapproveを2回呼び出す必要があるようにする。 - 記事が
Draft状態の時のみテキスト内容をユーザが追加できるようにする。 ヒント: ステートオブジェクトに内容について変わる可能性のあるものの責任を持たせつつも、Postを変更することには責任を持たせない。
ステートパターンの欠点の1つは、状態が状態間の遷移を実装しているので、状態の一部が密に結合した状態になってしまうことです。
PendingReviewとPublishedの間に、Scheduledのような別の状態を追加したら、
代わりにPendingReviewのコードをScheduledに遷移するように変更しなければならないでしょう。
状態が追加されてもPendingReviewを変更する必要がなければ、作業が減りますが、
そうすれば別のデザインパターンに切り替えることになるでしょう。
別の欠点は、ロジックの一部を重複させてしまうことです。重複を除くためには、
Stateトレイトのrequest_reviewとapproveメソッドにselfを返すデフォルト実装を試みる可能性があります;
ですが、これはオブジェクト安全性を侵害するでしょう。というのも、具体的なselfが一体なんなのかトレイトには知りようがないからです。
Stateをトレイトオブジェクトとして使用できるようにしたいので、メソッドにはオブジェクト安全になってもらう必要があるのです。
他の重複には、Postのrequest_reviewとapproveメソッドの実装が似ていることが含まれます。
両メソッドはOptionのstateの値に対する同じメソッドの実装に委譲していて、stateフィールドの新しい値を結果にセットします。
このパターンに従うPostのメソッドが多くあれば、マクロを定義して繰り返しを排除することも考慮する可能性があります
(第19章の「マクロ」節を参照)。
オブジェクト指向言語で定義されている通り忠実にステートパターンを実装することで、
Rustの強みをできるだけ発揮していません。blogクレートに対して行える無効な状態と遷移をコンパイルエラーにできる変更に目を向けましょう。
状態と振る舞いを型としてコード化する
ステートパターンを再考して別の代償を得る方法をお見せします。状態と遷移を完全にカプセル化して、 外部のコードに知らせないようにするよりも、状態を異なる型にコード化します。結果的に、 Rustの型検査システムが、公開記事のみが許可される箇所で草稿記事の使用を試みることをコンパイルエラーを発して阻止します。
リスト17-11のmainの最初の部分を考えましょう:
ファイル名: src/main.rs
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メソッドを全く持たないようにします。そうすると、草稿記事の内容を得ようとしたら、
メソッドが存在しないというコンパイルエラーになるでしょう。その結果、
誤ってプロダクションコードで草稿記事の内容を表示することが不可能になります。
そのようなコードは、コンパイルさえできないからです。リスト17-19はPost構造体、DraftPost構造体、
さらにメソッドの定義を示しています:
ファイル名: src/lib.rs
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);
}
}
リスト17-19: contentメソッドのあるPostとcontentメソッドのないDraftPost
PostとDraftPost構造体どちらにもブログ記事のテキストを格納する非公開のcontentフィールドがあります。
状態のコード化を構造体の型に移動したので、この構造体は最早stateフィールドを持ちません。
Postは公開された記事を表し、contentを返すcontentメソッドがあります。
それでもPost::new関数はありますが、Postのインスタンスを返すのではなく、DraftPostのインスタンスを返します。
contentは非公開であり、Postを返す関数も存在しないので、現状Postのインスタンスを生成することは不可能です。
DraftPost構造体には、以前のようにテキストをcontentに追加できるようadd_textメソッドがありますが、
DraftPostにはcontentメソッドが定義されていないことに注目してください!
従って、これでプログラムは、全ての記事が草稿記事から始まり、草稿記事は表示できる内容がないことを保証します。
この制限をかいくぐる試みは、全てコンパイルエラーに落ち着くでしょう。
遷移を異なる型への変形として実装する
では、どうやって公開された記事を得るのでしょうか?公開される前に草稿記事は査読され、
承認されなければならないという規則を強制したいです。査読待ち状態の記事は、それでも内容を表示するべきではありません。
別の構造体PendingReviewPostを追加し、DraftPostにPendingReviewPostを返すrequest_reviewメソッドを定義し、
PendingReviewPostにPostを返すapproveメソッドを定義してこれらの制限を実装しましょう。リスト17-20のようにですね:
ファイル名: src/lib.rs
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,
}
}
}
リスト17-20: DraftPostのrequest_reviewを呼び出すことで生成されるPendingReviewPostと、
PendingReviewPostを公開されたPostに変換するapproveメソッド
request_reviewとapproveメソッドはselfの所有権を奪い、故にDraftPostとPendingReviewPostインスタンスを消費し、
それぞれPendingReviewPostと公開されたPostに変形します。このように、
DraftPostインスタンスにrequest_reviewを呼んだ後には、DraftPostインスタンスは生きながらえず、
以下同様です。PendingReviewPost構造体には、contentメソッドが定義されていないので、
DraftPost同様に、その内容を読もうとするとコンパイルエラーに落ち着きます。
contentメソッドが確かに定義された公開されたPostインスタンスを得る唯一の方法が、
PendingReviewPostに対してapproveを呼び出すことであり、PendingReviewPostを得る唯一の方法が、
DraftPostにrequest_reviewを呼び出すことなので、これでブログ記事のワークフローを型システムにコード化しました。
ですが、さらにmainにも多少小さな変更を行わなければなりません。request_reviewとapproveメソッドは、
呼ばれた構造体を変更するのではなく、新しいインスタンスを返すので、let post =というシャドーイング代入をもっと追加し、
返却されたインスタンスを保存する必要があります。また、草稿と査読待ち記事の内容を空の文字列でアサートすることも、
する必要もありません: 最早、その状態にある記事の内容を使用しようとするコードはコンパイル不可能だからです。
mainの更新されたコードは、リスト17-21に示されています:
ファイル名: src/main.rs
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());
}
リスト17-21: ブログ記事ワークフローの新しい実装を使うmainの変更
postを再代入するためにmainに行う必要のあった変更は、この実装がもう、
全くオブジェクト指向のステートパターンに沿っていないことを意味します:
状態間の変形は最早、Post実装内に完全にカプセル化されていません。
ですが、型システムとコンパイル時に起きる型チェックのおかげでもう無効な状態があり得なくなりました。
これにより、未公開の記事の内容が表示されるなどの特定のバグが、プロダクションコードに移る前に発見されることが保証されます。
blogクレートに関してこの節の冒頭で提言される作業をそのままリスト17-21の後に試してみて、
このバージョンのコードについてどう思うか確かめてください。この設計では、
既に作業の一部が達成されている可能性があることに注意してください。
Rustは、オブジェクト指向のデザインパターンを実装する能力があるものの、状態を型システムにコード化するなどの他のパターンも、 Rustでは利用可能なことを確かめました。これらのパターンには、異なる代償があります。 あなたが、オブジェクト指向のパターンには非常に馴染み深い可能性があるものの、問題を再考してRustの機能の強みを活かすと、 コンパイル時に一部のバグを回避できるなどの利益が得られることもあります。オブジェクト指向のパターンは、 オブジェクト指向言語にはない所有権などの特定の機能によりRustでは、必ずしも最善の解決策ではないでしょう。
まとめ
この章読了後に、あなたがRustはオブジェクト指向言語であると考えるかどうかに関わらず、 もうトレイトオブジェクトを使用してRustでオブジェクト指向の機能の一部を得ることができると知っています。 ダイナミックディスパッチは、多少の実行時性能と引き換えにコードに柔軟性を齎してくれます。 この柔軟性を利用してコードのメンテナンス性に寄与するオブジェクト指向パターンを実装することができます。 Rustにはまた、オブジェクト指向言語にはない所有権などの他の機能もあります。オブジェクト指向パターンは、 必ずしもRustの強みを活かす最善の方法にはなりませんが、利用可能な選択肢の1つではあります。
次は、パターンを見ます。パターンも多くの柔軟性を可能にするRustの別の機能です。 本全体を通して僅かに見かけましたが、まだその全能力は目の当たりにしていません。さあ、行きましょう!