コンテンツにスキップ

Rustのデザインパターン

デザインパターンとは、何度も出てくる問題に対する、実績のある解き方のことです。パターンは、インストールするライブラリではありません。どちらかというとレシピに近いものです。「こういう問題があるときは、コードをこんな形にしよう」というものです。

Java や C# のパターンの多くは、Rust では違う形になったり、そもそも必要なかったりします。Rust には、強力な型システム、列挙型(enum)、トレイト、所有権をもとにした、Rust ならではの定番パターンがあります。そのほとんどに共通する大きな考え方は、ミスはコンパイラに見つけてもらうことです。そうすれば、ミスがユーザーのところまで届くことはありません。以下の各セクションでは、まず問題を示し、それからその問題を解決するパターンを紹介します。

ニュータイプ:意味が違えば型も違う

Section titled “ニュータイプ:意味が違えば型も違う”

問題。ただの f64 は、何でも表せてしまいます。メートル、フィート、秒、ユーロ……。メートル単位の f64 を受け取る関数にフィート単位の値を渡しても、コンパイラは助けてくれません。どちらもただの f64 だからです。(こうした単位の取り違えで、NASA が1999年に火星探査機マーズ・クライメイト・オービターを失ったのは有名な話です。)

パターン。値を、フィールドが1つだけの小さな構造体で包みます。これで Meters と Feet は別々の型になり、Rust はこの2つを取り違えることを許しません。

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("タワーは安全? {}", safe_altitude(tower));
println!("900フィートは{:.1}メートル", plane.to_meters().0);
println!("飛行機は安全? {}", safe_altitude(plane.to_meters()));
}

実行結果:

タワーは安全? true
900フィートは274.3メートル
飛行機は安全? false
▶ 実際に試してみよう

変換を忘れて safe_altitude(plane) と書くと、プログラムはコンパイルすらできません。

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

「型が合わない。Meters を期待したのに Feet が渡された」というエラーです。

ニュータイプは、実行時には何のコストもかかりません。メモリ上では、Meters はただの f64 そのものです。ID(UserId(u32) と OrderId(u32))、単位、検証済みの文字列などに使いましょう。孤児ルール(orphan rule)を回避するのにも使えます(高度なトレイトを見てください)。

ビルダー:たくさんのオプションから、わかりやすい1つの結果を

Section titled “ビルダー:たくさんのオプションから、わかりやすい1つの結果を”

問題。フィールドがたくさんあって、そのほとんどが省略可能な構造体があります。Email::new("[email protected]", "Hi", "", vec![], false) のようなコンストラクタは読みにくいですよね。どの false が何を意味するのでしょう? それに、アドレスがなかったり、間違っていたりしたらどうなるでしょう?

パターン。別のビルダー構造体を用意して、名前の付いた小さなメソッドで設定を集めます。どのメソッドもビルダーを返すので、呼び出しをチェーン(連結)できます。最後の build() がすべてをチェックして、Result を返します。

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("メールには宛先が必要です")?;
if !to.contains('@') {
return Err(format!("'{}' はメールアドレスではありません", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(件名なし)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("準備完了: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("エラー: {}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("ランチどう?")
.body("12時にピザはどう?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("こんにちは").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

実行結果:

準備完了: [email protected] subject=ランチどう? body="12時にピザはどう?" urgent=true
準備完了: [email protected] subject=(件名なし) body="" urgent=false
エラー: メールには宛先が必要です
エラー: 'ana.example.com' はメールアドレスではありません
▶ 実際に試してみよう

それぞれのセッターは mut self を受け取り(ビルダーの所有権を受け取ります)、フィールドを1つ変えて、ビルダーを返します。これでチェーンができるのです。呼び出す側は気になるものだけを設定すればよく、メソッド名が値の意味を説明してくれます。そして、ルールをチェックするのは build() の1か所だけです。その中では、ok_or が宛先のない None を Err に変え、? がそのエラーを早めに返します。ビルダーはたくさんのクレートで使われています。たとえば std::thread::Builder や std::process::Command です(環境変数とプロセスを見てください)。

Default と構造体更新記法:手軽な選択肢

Section titled “Default と構造体更新記法:手軽な選択肢”

問題。ビルダーはコードがたくさん必要です。チェックすべきルールがなく、ただ妥当な初期値があればいいだけの構造体には、大げさすぎます。

パターン。よい初期値で Default を実装し、構造体更新記法 .. を使って、気になるフィールドだけを変えます。

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// デフォルトではなく、別の設定をもとにする:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

実行結果:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ 実際に試してみよう

..something は「書かなかったフィールドは全部 something から持ってくる」という意味です。なぜ prod.clone() なのでしょうか? 構造体更新記法は、残りのフィールドをムーブします。そして host は String です。clone しないと prod は一部がムーブされた状態になり、あとで表示できなくなってしまいます。目安としては、どんな値の組み合わせでも問題ないなら Default と .. を、無効な組み合わせがあるならビルダーを使いましょう。

タイプステート:コンパイル時にチェックされる状態機械

Section titled “タイプステート:コンパイル時にチェックされる状態機械”

問題。順番どおりに起こらなければならないことがあります。Web のリクエストは、URL を設定してからでないと送れません。ふつうの構造体なら、実行時にチェックすることになります(「URL は設定されている? されていなければエラーを返す」)。そして、すべての呼び出し側がそのエラーをきちんと処理してくれることを祈るしかありません。

パターン。状態を型の一部にします。Request<NoUrl> には、そもそも send メソッドがありません。URL を設定したときだけ Request<HasUrl> に変わり、そこで初めて send が使えるようになります。

// 2つの状態。データは持たない(か、その状態に必要なものだけを持つ)。
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// 型を変える:Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// どの状態でも使える
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// URL が設定されてからしか使えない
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {}(ヘッダー{}個)", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

実行結果:

GET https://example.com(ヘッダー2個)
▶ 実際に試してみよう

では、URL なしで送ってみましょう。Request::new().send() と書くと、コンパイラが止めてくれます。

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

「Request<NoUrl> には send というメソッドがない。Request<HasUrl> にはある」というエラーです。

コンパイルが通るプログラムには、このバグは存在しえません。実行時のチェックも、余計なコストもありません。NoUrl のサイズは0バイトです。url() は self を値で受け取るので、古い Request<NoUrl> は使い切られます。だから、うっかり「設定前」の状態を使い続けることもできません。これはジェネリクスの巧みな使い方です。Playground で少し書き換えてみて、どう動くのかを体感してみる価値があります。

RAII と Drop ガード:忘れようのない後片付け

Section titled “RAII と Drop ガード:忘れようのない後片付け”

問題。リソースには後片付けが必要です。ファイルを閉じる、ロックを解除する、一時フォルダを消す、「完了」と表示する。後片付けが「忘れずに呼ばなければならない関数」だと、誰かが必ず忘れます。早めの return やエラーのときは特にそうです。

パターン。後片付けを Drop に書きます。Rust は、値がスコープを抜けるときに自動で drop を呼びます。ふつうに終わったときも、早めに return したときも、? のときも、パニックのときでさえ、どの道筋で抜けても呼ばれます。これを RAII(Resource Acquisition Is Initialization:リソースの取得と、値を所有することが同じ一歩になる)と呼びます。後片付けだけが仕事の値は、ガードと呼ばれます。

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("{}を開始", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("{}を後片付け", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("危険な作業");
if fail {
return Err(String::from("問題が起きました")); // それでもガードは後片付けする
}
println!("作業完了");
Ok(())
}
fn main() {
{
let _block = Guard::new("ブロック");
println!("ブロックの中");
} // _block はここで破棄される
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

実行結果:

ブロックを開始
ブロックの中
ブロックを後片付け
危険な作業を開始
作業完了
危険な作業を後片付け
Ok(())
危険な作業を開始
危険な作業を後片付け
Err("問題が起きました")
▶ 実際に試してみよう

このパターンは、実はもういつも使っています。File は破棄されるときに自分で閉じます。MutexGuard(mutex.lock() が返すもの。並行処理を見てください)は、破棄されるときにミューテックスのロックを解除します。Box は、破棄されるときにメモリを解放します(スマートポインタ)。Rust のコードに「close」や「free」の呼び出しがほとんど出てこないのは、このためです。

From と Into:引数を受け取りやすくする

Section titled “From と Into:引数を受け取りやすくする”

問題。関数が String を受け取るので、&str を持っている呼び出し側は、毎回 String::from("Ana") や "Ana".to_string() と書かなければなりません。あるいは、関数は UserId を受け取るのに、呼び出し側が持っているのはただの u32 だったりします。小さなことですが、あちこちでコードがごちゃごちゃします。

パターン。自分の型に From を実装し、関数では impl Into<T> を受け取るようにします。そうすれば、呼び出し側は変換できるものなら何でも渡せます。

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 と &str
let ben = User::new(UserId(2), String::from("Ben")); // 最初から正しい型
println!("{:?}", ana);
println!("{:?}", ben);
}

実行結果:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ 実際に試してみよう

実装するのは From にして、Into を直接実装することはしません。Into はおまけで付いてくるからです。同じ仕組みがエラー処理も支えています。? はエラーに対して From::from を呼ぶので、From<io::Error> for MyError を実装しておけば、? がエラーを変換してくれます(独自のエラー型を見てください)。これは失敗しない変換に使いましょう。失敗するかもしれない変換には、Result を返す TryFrom を使います。

enum の状態機械と、ありえない状態

Section titled “enum の状態機械と、ありえない状態”

問題。注文は「カートの中」から「支払い済み」、そして「発送済み」へと進みます。最初に書いてみると、よくこんな形になります。

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

でもこれだと、おかしな状態を作れてしまいます。paid: false なのに shipped: true だったり、まだ発送していない注文に追跡番号があったり。どの関数でも、こうしたありえない組み合わせをチェックしなければなりません。

パターン。ありえない状態を表現できないようにします。状態ごとに1つのバリアントを持つ enum を使い、各バリアントにはその状態が本当に持つデータだけを持たせます。状態の遷移は、古い状態を受け取って新しい状態を返すメソッドにします。

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // 支払いできるのはカートだけ。それ以外はそのまま
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // キャンセルするには遅すぎる
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("カート(商品{}個)", items.len()),
Order::Paid { items, amount } => format!("支払い済み:金額{}(商品{}個)", amount, items.len()),
Order::Shipped { tracking } => format!("発送済み、追跡番号 {}", tracking),
Order::Cancelled => String::from("キャンセル済み"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("本"), String::from("ペン")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // まだ支払っていない:何も起きない
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // もう発送済み
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

実行結果:

カート(商品2個)
カート(商品2個)
発送済み、追跡番号 TRACK-42
発送済み、追跡番号 TRACK-42
キャンセル済み
▶ 実際に試してみよう

発送済みの注文に追跡番号がないということはありえません。Shipped バリアントは必ず追跡番号を持っているからです。カートが追跡番号を持つこともありえません。そのためのフィールドがないからです。そして、match はすべてのバリアントを網羅しなければならないので、新しい状態(たとえば Refunded)を追加すると、直すべき場所をすべてコンパイラが教えてくれます。

enum か、トレイトオブジェクトか? オブジェクト指向の言語では、「State」インターフェースを用意して、状態ごとに1つのクラスを作ることがよくあります。Rust でもトレイトオブジェクト(Box<dyn State>)を使えば同じことができ、Rust Book にもそのやり方が載っています。選び方は次のとおりです。

  • enum:すべての状態が最初からわかっている場合。match の網羅性チェックが効き、ロジックが1か所にまとまります。Rust ではふつうこちらを選びます。
  • トレイトオブジェクト:ほかの人(やほかのクレート)が、あなたのコードを変えずに新しい状態を追加する必要がある場合。
  • タイプステート(前のセクション):呼び出す順番の間違いを、実行時に無視するのではなく、コンパイルエラーにしたい場合。より厳格ですが、状態がコンパイル時にわかっている必要があります。

let _guard = guard ではなく let _ = guard と書く。ほとんど同じに見えますが、動きが違います。アンダースコアで始まる名前(_guard など)は、値をスコープの終わりまで生かしておきます。ただの _ は変数ですらないので、値はすぐに破棄されます。

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("ガードが破棄された");
}
}
fn main() {
let _ = Guard;
println!("`let _` で作業中");
let _g = Guard;
println!("`let _g` で作業中");
}

実行結果:

ガードが破棄された
`let _` で作業中
`let _g` で作業中
ガードが破棄された
▶ 実際に試してみよう

let _ だと、後片付けが作業の前に走ってしまいました。ロックやタイマーにとっては、まさに最悪の動きです。ガードには、必ず名前の付いた変数を使いましょう。

パターンがあるから、というだけで使う。フィールドが2つしかない構造体にビルダーを作ったり、誤用されることのないものにタイプステートを使ったりしても、コードが増えるだけで何も安全になりません。まずはシンプルに始めましょう。パターンを使うのは、そのパターンが解決する問題を実際に感じたときです。

  • ニュータイプ(struct Meters(f64))は、同じデータに違う型を与えます。取り違えはコンパイラが見つけてくれて、実行時のコストはかかりません。
  • ビルダーは、読みやすいチェーン形式の設定と、検証して Result を返す1つの build() を提供します。チェックが何も要らないなら、Default と .. のほうが手軽です。
  • タイプステートは状態を型に入れます(Request<NoUrl> と Request<HasUrl>)。だから、間違った順番で呼び出すとコンパイルが通りません。
  • RAII / Drop ガードは、スコープをどう抜けても、後片付けを自動で実行します。
  • From/Into を使うと、関数は impl Into<T> でいろいろな型の入力を受け取れます。
  • enum で状態機械を表し、ありえない状態を表現できないようにします。トレイトオブジェクトを使うのは、ほかの人が状態を追加する必要があるときだけにしましょう。

理解度チェック

1. ニュータイプ struct Meters(f64) を使うと、ただの f64 と比べて何がうれしいですか?

2. このページのビルダーパターンで、build() は何を返しますか?

3. タイプステートの例で、Request::new() に対して send() を呼べないのはなぜですか?

4. Drop ガードの後片付けが実行されるのはいつですか?

5. let _ = Guard; はどうなりますか?

スコア: 0 / 5