Rust: 制御フロー(3/6)
ほとんど1すべての有用なプログラムは、条件に基づいて何らかの判断を行うか、何らかのロジックを複数回実行します。 したがって、すべての命令型プログラミング言語は、制御フローを決定するための何らかの仕組みを提供します。つまり、個々の文が実行される順序を決める仕組みです。
言語は、制御フローを表現するために同じような少数の構文に落ち着く傾向があります。 Rust も例外ではありません。 Rust のパターンマッチングは、あなたがどの言語から来たかによっては新しいものかもしれませんが、条件文やループは馴染みのあるものに感じられるはずです。
条件文
if キーワードと else キーワードは、おおむね期待どおりに動作します。
fn conditional_print(num: usize) {
if num > 10 {
println!("{} is greater than 10.", num);
} else if num % 2 == 0 {
println!("{} is even.", num);
} else {
println!("{} is odd.", num);
}
}
fn main() {
conditional_print(11);
conditional_print(4);
conditional_print(5);
}
上記の出力は次のとおりです。
11 is greater than 10.
4 is even.
5 is odd.
Rust が異なる点は、if キーワードの後の条件が bool 型に評価されなければならないことです。
暗黙的なキャストは許可されません。
この厳格さは、別の MISRA ルールに従う助けになります。
[AR, Rule 14.4] if 式は boolean 型に評価されなければならない2
他の多くの言語では、条件文に対して厳密な型付けを強制していません。
-
Python では、条件が
None値に評価されると、それは暗黙的にfalseにキャストされます。 -
同様に C では、ゼロの整数は暗黙的に
falseにキャストされます(非ゼロはtrueにキャストされます)。
これは、Rust で条件を表現する能力を妨げるものではありません。
x == None や y != 0 は、依然として明示的に書き下すことができます。
しかし、潜在的なエラーの原因を 1 つ取り除くことにはなります。
While ループ
while キーワードを使うと、boolean 条件が成り立っている限りループを実行し続けることができます。
以下は 10 から 1 までのカウントダウンを出力します。
#![allow(unused)]
fn main() {
let mut countdown = 10;
while countdown > 0 {
println!("{}...", countdown);
countdown -= 1;
}
}
Rust は「do while」ループを直接サポートしていませんが、同じロジックは loop キーワードと break キーワードを使って実装できます。
同等のカウントダウンは次のように実装できます。
#![allow(unused)]
fn main() {
let mut countdown = 10;
loop {
println!("{}...", countdown);
countdown -= 1;
if countdown == 0 {
break;
}
}
}
For ループ
for キーワードを使うと、任意のイテラブルをループできます。
範囲を例に取りましょう。
以下は 0 から 9 までの数値を出力します。
#![allow(unused)]
fn main() {
for i in 0..10 {
println!("{}", i);
}
}
ループ内でコレクションの要素にアクセスしたい場合はどうでしょうか。
表面的には、私たちの for 構文は「そのまま動く」ように見えます。
#![allow(unused)]
fn main() {
use std::collections::{HashSet, BTreeSet};
// リスト
let list = vec![3, 2, 1];
println!("Iterating over vector:");
for item in list {
println!("list item: {}", item);
}
// 順序付き集合
let mut o_set = BTreeSet::new();
o_set.insert(3);
o_set.insert(2);
o_set.insert(1);
println!("\nIterating over ordered set:");
for elem in o_set {
println!("set element: {}", elem);
}
// ハッシュ集合
let mut h_set = HashSet::new();
h_set.insert(3);
h_set.insert(2);
h_set.insert(1);
println!("\nIterating over hash set:");
for elem in h_set {
println!("set element: {}", elem);
}
}
しかし、上記の出力を考えてみましょう。
Iterating over vector:
list item: 3
list item: 2
list item: 1
Iterating over ordered set:
set element: 1
set element: 2
set element: 3
Iterating over hash set:
set element: 2
set element: 3
set element: 1
各コレクションには、要素にアクセスするための独自の戦略があります。
Vec(リスト)は、値を挿入された順序で返します。BTreeSet(順序付き集合)は、互いに相対的なソート順で値を返します。HashSet(ハッシュ集合)には、ソート順であれ挿入順であれ、順序という概念がありません。
内部では、各コレクションが独自のイテレーターを実装しています。
それぞれ独自のロジックを持っていますが、共通のインターフェイスである Iterator トレイトを共有しています3。
for ループはこのインターフェイスを活用して、基盤となるデータ構造の走査を行います。
イテレーターは慣用的な Rust の重要な一部であり、独自のイテレーターを実装することに丸ごと 1 章を割きます。 今は、イテレーターが便利さの世界を可能にすることを知っておいてください。 たとえば列挙です。
#![allow(unused)]
fn main() {
let list = vec![3, 2, 1];
for (i, item) in list.iter().enumerate() {
println!("list item {}: {}", i, item);
}
// 出力:
//
// list item 0: 3
// list item 1: 2
// list item 2: 1
}
そして関数型の変換です。
#![allow(unused)]
fn main() {
let list = vec![3, 2, 1];
let triple_list: Vec<_> = list.iter().map(|x| x * 3).collect();
for item in triple_list {
println!("triple_list item: {}", item);
}
// 出力:
// triple_list item: 9
// triple_list item: 6
// triple_list item: 3
}
イテレーターは、範囲外(Out-Of-Bounds、OOB)インデックス指定のような一般的なエラーも防ぎます。 これは次に準拠する助けになります。
[AR, Rule 14.2] for ループは整形式でなければならない2
パターンマッチング
最も単純な使い方では、パターンマッチングは C の switch 文に似ています。有限集合から 1 つのアクションを選択します。
前のセクションでは、enum バリアントに対する match を見ました。
これは、ドメイン固有のコンテキストに基づいて異なるアクションを取る便利な方法になり得ます。
復習すると、次のとおりです。
#[derive(Debug)]
pub enum State {
Running,
Stopped,
Sleeping,
}
fn do_something_based_on_state(curr_state: State, pid: u32) {
match curr_state {
State::Running => stop_running_process(pid),
State::Stopped => restart_stopped_process(pid),
State::Sleeping => wake_sleeping_process(pid),
}
}
C の switch とは異なり、パターンマッチングでは式のリストと、それぞれに対応するアクションを指定できます。
式を使うと、比較的複雑な条件を簡潔にエンコードできます。
例:
#![allow(unused)]
fn main() {
let x = 10;
match x {
1 | 2 | 3 => println!("number is 1 or 2 or 3"),
4..=10 => println!("number is between 4 and 10 inclusive"),
x if x * x < 250 => println!("number squared is less than 250"),
_ => println!("number didn't meet any previous condition!"),
}
}
-
1つ目のmatchアーム(
1 | 2 | 3 => ...)は、3つのリテラル値を指定しています。マッチ対象の変数xがその3つのいずれかと等しい場合にトリガーされます。 -
2つ目のアームは、4から10までを含む範囲を指定しています。
xがその範囲内のいずれかの値である場合にトリガーされます。 -
3つ目のアームはガード式を使用します。
xにそれ自身を掛けた値が250未満の場合にトリガーされます。 -
4つ目で最後のアームはデフォルトケースです。ワイルドカード
_を使ってあらゆるものにマッチします。前のケースがどれもトリガーされなかった場合にのみトリガーされます。
入力は複数のアームにマッチすることはできず、適合する最初のパターンにのみマッチすることに注意してください。 したがって、順序は重要です。
Rustでは、マッチが網羅的であることも要求されます。つまり、プログラマーは考えられるすべてのケースを処理しなければなりません。
最初の例で State バリアントを網羅的にマッチするのは簡単でした。Running、Stopped、Sleeping の3つしかないためです。
2つ目の例では、let x = 10; は x の型を指定していませんでした。
そのため、コンパイラはデフォルトで i32 と推論しました。
32ビット符号なし整数の考えられるすべての値を網羅的にマッチするのは面倒です。その代わりに、各パターンは考えられる値のサブセットをカバーしています。
4つ目のパターンであるワイルドカードのデフォルトは、何も見逃さないようにするために必要です。
その行が省かれていた場合、たとえば x が 16 であるケースを処理できません。
網羅性の要件により、私たちが書くどの match も考えられるあらゆる入力を適切に処理することが保証されます。これは、別のMISRAルールの精神に合致します。
[AR, Rule 16.4] switch文にはデフォルトケースがなければならない2
このルールはCの switch 文に特化したものですが、堅牢なマッチングという考え方は引き継がれます。適切なアクションを取らずに、switch/match をうっかり「フォールスルー」してしまうことは決して避けるべきです。
簡潔なパターンマッチング
Rustは、特定のパターンが適合したときにトリガーされる単一の条件付きアクションへとパターンマッチングを簡潔にまとめる構文を提供しています(残りは無視します)。
Rustコードで if let や while let を見かけた場合、それは単一の match アームへ「掘り下げる」ための省略記法です。
この構文は最初のうちは分かりにくいことがあるため、本書の後半で、より大きなプログラムの文脈の中で徐々に紹介していきます。
予告として、次のコードを考えてみましょう(前に使った State enumを使用していると仮定します)。
let curr_state = State::Running;
match curr_state {
State::Running => println!("Process is running!"),
State::Stopped => {}, // 何もしない
State::Sleeping => {}, // 何もしない
};
これは、次の省略記法と同等です。
let curr_state = State::Running;
if let State::Running = curr_state {
println!("Process is running!");
}
Running 状態の場合にだけメッセージを出力していますが、異なるケースを網羅的に match する必要はないことに注目してください。
その代わりに、if let によって特定の enum バリアントに対してのみ条件付きアクションを実行できます。
前述のMISRAルールに照らすと、他のケースを無視することで堅牢性を失っているのでしょうか? 少し意外かもしれませんが、必ずしもそうではありません。
-
if letは他のif文と同様に、特定の条件が真である場合にのみ本体が実行されます。設計上、網羅的であることを意図していません。ifは1つのケースにだけ「関心」を持ちます。そしてそれは読み手にとって明らかです。 -
matchは複数のパターンをサポートし、入力がどれをトリガーするかを知りません。設計上、それらすべてを処理する責任があります。そのため、コンパイラが網羅性を強制します。そうでなければ、読み手が見落とす可能性があります。
match と if let のどちらが適切かを判断することは、より広いプログラムの文脈に依存します。
まとめ
Rustの制御フロー構文は、他のプログラミング言語と大きく異なるわけではありません。
while ループは期待どおりに動作し、for ループはイテレーターに支えられており、「do while」は別の構文でエミュレートできます。
少し厳格な点があります。条件はブール値に評価されなければならず、if let を使わない場合、パターンマッチングは網羅的でなければなりません。
Rustは正しさの概念を促します。
背景によっては、パターンマッチングは初めてかもしれません。 その用途は、バリアントに対する単純な分岐から、複雑なパターンの精巧なマッチングまでさまざまです。 しかし、複雑なパターンが必要になることはおそらく多くありません。 そして必要になったときには、その機能が存在することをありがたく思うでしょう!
データ表現と制御フローについて見てきました。 次は、Rustをユニークにしているものを掘り下げるときです。 この言語の最も特徴的で新しい機能、すなわち所有権です。
-
ブランチレスプログラミング。本当に重要なのか?. Jobin Johnson (2021). ↩
-
MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition). MISRA (2019). ↩ ↩2 ↩3
-
トレイト
std::iter::Iterator. The Rust Team (2022年アクセス). ↩