リファクタリングしてモジュール性とエラー処理を向上させる
プログラムを改善するために、プログラムの構造と起こりうるエラーに対処する方法に関連する4つの問題を修正していきましょう。
1番目は、main
関数が2つの仕事を受け持っていることです: 引数を解析し、ファイルを開いています。
このような小さな関数なら、これは、大した問題ではありませんが、main
内でプログラムを巨大化させ続けたら、
main
関数が扱う個別の仕事の数も増えていきます。関数が責任を受け持つごとに、
正しいことを確認しにくくなり、テストも行いづらくなり、機能を壊さずに変更するのも困難になっていきます。
機能を小分けして、各関数が1つの仕事のみに責任を持つようにするのが最善です。
この問題は、2番目の問題にも結びついています: query
とfilename
はプログラムの設定用変数ですが、
f
やcontents
といった変数は、プログラムのロジックを担っています。main
が長くなるほど、
スコープに入れるべき変数も増えます。そして、スコープにある変数が増えれば、各々の目的を追うのも大変になるわけです。
設定用変数を一つの構造に押し込め、目的を明瞭化するのが最善です。
3番目の問題は、ファイルを開き損ねた時にexpect
を使ってエラーメッセージを出力しているのに、
エラーメッセージがファイルが見つかりませんでした
としか表示しないことです。
ファイルを開く行為は、ファイルが存在しない以外にもいろんな方法で失敗することがあります:
例えば、ファイルは存在するかもしれないけれど、開く権限がないかもしれないなどです。
現時点では、そのような状況になった時、「ファイルが見つかりませんでした」というエラーメッセージを出力し、
これはユーザに間違った情報を与えるのです。
4番目は、異なるエラーを処理するのにexpect
を繰り返し使用しているので、ユーザが十分な数の引数を渡さずにプログラムを起動した時に、
問題を明確に説明しない「範囲外アクセス(index out of bounds)」というエラーがRustから得られることです。
エラー処理のコードが全て1箇所に存在し、将来エラー処理ロジックが変更になった時に、
メンテナンス者が1箇所のコードのみを考慮すればいいようにするのが最善でしょう。
エラー処理コードが1箇所にあれば、エンドユーザにとって意味のあるメッセージを出力していることを確認することにもつながります。
プロジェクトをリファクタリングして、これら4つの問題を扱いましょう。
バイナリプロジェクトの責任の分離
main
関数に複数の仕事の責任を割り当てるという構造上の問題は、多くのバイナリプロジェクトでありふれています。
結果として、main
が肥大化し始めた際にバイナリプログラムの個別の責任を分割するためにガイドラインとして活用できる工程をRustコミュニティは、
開発しました。この工程は、以下のような手順になっています:
- プログラムをmain.rsとlib.rsに分け、ロジックをlib.rsに移動する。
- コマンドライン引数の解析ロジックが小規模な限り、main.rsに置いても良い。
- コマンドライン引数の解析ロジックが複雑化の様相を呈し始めたら、main.rsから抽出してlib.rsに移動する。
この工程の後にmain
関数に残る責任は以下に限定される:
- 引数の値でコマンドライン引数の解析ロジックを呼び出す
- 他のあらゆる設定を行う
- lib.rsの
run
関数を呼び出す run
がエラーを返した時に処理する
このパターンは、責任の分離についてです: main.rsはプログラムの実行を行い、
そして、lib.rsが手にある仕事のロジック全てを扱います。main
関数を直接テストすることはできないので、
この構造により、プログラムのロジック全てをlib.rsの関数に移すことでテストできるようになります。
main.rsに残る唯一のコードは、読めばその正当性が評価できるだけ小規模になるでしょう。
この工程に従って、プログラムのやり直しをしましょう。
引数解析器を抽出する
引数解析の機能をmain
が呼び出す関数に抽出して、コマンドライン引数解析ロジックをsrc/lib.rsに移動する準備をします。
リスト12-5に新しい関数parse_config
を呼び出すmain
の冒頭部を示し、
この新しい関数は今だけsrc/main.rsに定義します。
ファイル名: src/main.rs
fn main() {
let args: Vec<String> = env::args().collect();
let (query, filename) = parse_config(&args);
// --snip--
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let filename = &args[2];
(query, filename)
}
それでもまだ、コマンドライン引数をベクタに集結させていますが、main
関数内で引数の値の添え字1を変数query
に、
添え字2を変数filename
に代入する代わりに、ベクタ全体をparse_config
関数に渡しています。
そして、parse_config
関数にはどの引数がどの変数に入り、それらの値をmain
に返すというロジックが存在します。
まだmain
内にquery
とfilename
という変数を生成していますが、もうmain
は、
コマンドライン引数と変数がどう対応するかを決定する責任は持ちません。
このやり直しは、私たちの小規模なプログラムにはやりすぎに思えるかもしれませんが、 少しずつ段階的にリファクタリングしているのです。この変更後、プログラムを再度実行して、 引数解析がまだ動作していることを実証してください。問題が発生した時に原因を特定する助けにするために頻繁に進捗を確認するのはいいことです。
設定値をまとめる
もう少しparse_config
関数を改善することができます。現時点では、タプルを返していますが、
即座にタプルを分解して再度個別の値にしています。これは、正しい抽象化をまだできていないかもしれない兆候です。
まだ改善の余地があると示してくれる他の徴候は、parse_config
のconfig
の部分であり、
返却している二つの値は関係があり、一つの設定値の一部にどちらもなることを暗示しています。
現状では、一つのタプルにまとめていること以外、この意味をデータの構造に載せていません;
この二つの値を1構造体に置き換え、構造体のフィールドそれぞれに意味のある名前をつけることもできるでしょう。
そうすることで将来このコードのメンテナンス者が、異なる値が相互に関係する仕方や、目的を理解しやすくできるでしょう。
注釈: この複雑型(complex type)がより適切な時に組み込みの値を使うアンチパターンを、 primitive obsession(
訳注
: 初めて聞いた表現。組み込み型強迫観念といったところだろうか)と呼ぶ人もいます。
リスト12-6は、parse_config
関数の改善を示しています。
ファイル名: src/main.rs
use std::env; use std::fs::File; fn main() { let args: Vec<String> = env::args().collect(); let config = parse_config(&args); println!("Searching for {}", config.query); println!("In file {}", config.filename); let mut f = File::open(config.filename).expect("file not found"); // --snip-- } struct Config { query: String, filename: String, } fn parse_config(args: &[String]) -> Config { let query = args[1].clone(); let filename = args[2].clone(); Config { query, filename } }
query
とfilename
というフィールドを持つよう定義されたConfig
という構造体を追加しました。
parse_config
のシグニチャは、これでConfig
値を返すと示すようになりました。parse_config
の本体では、
以前はargs
のString
値を参照する文字列スライスを返していましたが、
今では所有するString
値を含むようにConfig
を定義しています。main
のargs
変数は引数値の所有者であり、
parse_config
関数だけに借用させていますが、これはConfig
がargs
の値の所有権を奪おうとしたら、
Rustの借用規則に違反してしまうことを意味します。
String
のデータは、多くの異なる手法で管理できますが、最も単純だけれどもどこか非効率的な手段は、
値に対してclone
メソッドを呼び出すことです。これにより、Config
インスタンスが所有するデータの総コピーが生成されるので、
文字列データへの参照を保持するよりも時間とメモリを消費します。ですが、データをクローンすることで、
コードがとても素直にもなります。というのも、参照のライフタイムを管理する必要がないからです。
つまり、この場面において、少々のパフォーマンスを犠牲にして単純性を得るのは、価値のある代償です。
clone
を使用する代償実行時コストのために
clone
を使用して所有権問題を解消するのを避ける傾向が多くのRustaceanにあります。 第13章で、この種の状況においてより効率的なメソッドの使用法を学ぶでしょう。ですがとりあえずは、 これらのコピーをするのは1回だけですし、ファイル名とクエリ文字列は非常に小さなものなので、 いくつかの文字列をコピーして進捗するのは良しとしましょう。最初の通り道でコードを究極的に効率化しようとするよりも、 ちょっと非効率的でも動くプログラムを用意する方がいいでしょう。もっとRustの経験を積めば、 最も効率的な解決法から開始することも簡単になるでしょうが、今は、clone
を呼び出すことは完璧に受け入れられることです。
main
を更新したので、parse_config
から返されたConfig
のインスタンスをconfig
という変数に置くようになり、
以前は個別のquery
とfilename
変数を使用していたコードを更新したので、代わりにConfig
構造体のフィールドを使用するようになりました。
これでコードはquery
とfilename
が関連していることと、その目的がプログラムの振る舞い方を設定するということをより明確に伝えます。
これらの値を使用するあらゆるコードは、config
インスタンスの目的の名前を冠したフィールドにそれらを発見することを把握しています。
Config
のコンストラクタを作成する
ここまでで、コマンドライン引数を解析する責任を負ったロジックをmain
から抽出し、parse_config
関数に配置しました。
そうすることでquery
とfilename
の値が関連し、その関係性がコードに載っていることを確認する助けになりました。
それからConfig
構造体を追加してquery
とfilename
の関係する目的を名前付けし、
構造体のフィールド名としてparse_config
関数からその値の名前を返すことができています。
したがって、今やparse_config
関数の目的はConfig
インスタンスを生成することになったので、
parse_config
をただの関数からConfig
構造体に紐づくnew
という関数に変えることができます。
この変更を行うことで、コードがより慣用的になります。String
などの標準ライブラリの型のインスタンスを、
String::new
を呼び出すことで生成できます。同様に、parse_config
をConfig
に紐づくnew
関数に変えれば、
Config::new
を呼び出すことでConfig
のインスタンスを生成できるようになります。リスト12-7が、
行う必要のある変更を示しています。
ファイル名: src/main.rs
use std::env; fn main() { let args: Vec<String> = env::args().collect(); let config = Config::new(&args); // --snip-- } struct Config { query: String, filename: String, } // --snip-- impl Config { fn new(args: &[String]) -> Config { let query = args[1].clone(); let filename = args[2].clone(); Config { query, filename } } }
parse_config
を呼び出していたmain
を代わりにConfig::new
を呼び出すように更新しました。
parse_config
の名前をnew
に変え、impl
ブロックに入れ込んだので、new
関数とConfig
が紐づくようになりました。
再度このコードをコンパイルしてみて、動作することを確かめてください。
エラー処理を修正する
さて、エラー処理の修正に取り掛かりましょう。ベクタが2個以下の要素しか含んでいないときにargs
ベクタの添え字1か2にアクセスしようとすると、
プログラムがパニックすることを思い出してください。試しに引数なしでプログラムを実行してください。すると、こんな感じになります:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1
but the index is 1', src/main.rs:29:21
(スレッド'main'は、「境界外アクセス: 長さは1なのに添え字も1です」でパニックしました)
note: Run with `RUST_BACKTRACE=1` for a backtrace.
境界外アクセス: 長さは1なのに添え字も1です
という行は、プログラマ向けのエラーメッセージです。
エンドユーザが起きたことと代わりにすべきことを理解する手助けにはならないでしょう。これを今修正しましょう。
エラーメッセージを改善する
リスト12-8で、new
関数に、添え字1と2にアクセスする前にスライスが十分長いことを実証するチェックを追加しています。
スライスの長さが十分でなければ、プログラムはパニックし、境界外インデックス
よりもいいエラーメッセージを表示します。
ファイル名: src/main.rs
// --snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
// 引数の数が足りません
panic!("not enough arguments");
}
// --snip--
このコードは、リスト9-9で記述したvalue
引数が正常な値の範囲外だった時にpanic!
を呼び出したGuess::new
関数と似ています。
ここでは、値の範囲を確かめる代わりに、args
の長さが少なくとも3であることを確かめていて、
関数の残りの部分は、この条件が満たされているという前提のもとで処理を行うことができます。
args
に2要素以下しかなければ、この条件は真になり、panic!
マクロを呼び出して、即座にプログラムを終了させます。
では、new
のこの追加の数行がある状態で、再度引数なしでプログラムを走らせ、エラーがどんな見た目か確かめましょう:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments', src/main.rs:30:12
(スレッド'main'は「引数が足りません」でパニックしました)
note: Run with `RUST_BACKTRACE=1` for a backtrace.
この出力の方がマシです: これでエラーメッセージが合理的になりました。ですが、
ユーザに与えたくない追加の情報も含まれてしまっています。おそらく、
ここではリスト9-9で使用したテクニックを使用するのは最善ではありません:
panic!
の呼び出しは、第9章で議論したように、使用の問題よりもプログラミング上の問題により適しています。
代わりに、第9章で学んだもう一つのテクニックを使用することができます。成功か失敗かを示唆するResult
を返すことです。
panic!
を呼び出す代わりにnew
からResult
を返す
代わりに、成功時にはConfig
インスタンスを含み、エラー時には問題に言及するResult
値を返すことができます。
Config::new
がmain
と対話する時、Result
型を使用して問題があったと信号を送ることができます。
それからmain
を変更して、panic!
呼び出しが引き起こしていたthread 'main'
とRUST_BACKTRACE
に関する周囲のテキストがない、
ユーザ向けのより実用的なエラーにErr
列挙子を変換することができます。
リスト12-9は、Config::new
の戻り値に必要な変更とResult
を返すのに必要な関数の本体を示しています。
main
も更新するまで、これはコンパイルできないことに注意してください。その更新は次のリストで行います。
ファイル名: src/main.rs
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename })
}
}
new
関数は、これで、成功時にはConfig
インスタンスを、エラー時には&'static str
を伴うResult
を返すようになりました。
第10章の「静的ライフタイム」節から&'static str
は文字列リテラルの型であることを思い出してください。
これは、今はエラーメッセージの型になっています。
new
関数の本体で2つ変更を行いました: 十分な数の引数をユーザが渡さなかった場合にpanic!
を呼び出す代わりに、
今はErr
値を返し、Config
戻り値をOk
に包んでいます。これらの変更により、関数が新しい型シグニチャに適合するわけです。
Config::new
からErr
値を返すことにより、main
関数は、new
関数から返ってくるResult
値を処理し、
エラー時により綺麗にプロセスから抜け出すことができます。
Config::new
を呼び出し、エラーを処理する
エラーケースを処理し、ユーザフレンドリーなメッセージを出力するために、main
を更新して、
リスト12-10に示したようにConfig::new
から返されているResult
を処理する必要があります。
また、panic!
からコマンドラインツールを0以外のエラーコードで抜け出す責任も奪い取り、
手作業でそれも実装します。0以外の終了コードは、
我々のプログラムを呼び出したプロセスにプログラムがエラー状態で終了したことを通知する慣習です。
ファイル名: src/main.rs
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
// 引数解析時に問題
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
// --snip--
このリストにおいて、以前には講義していないメソッドを使用しました: unwrap_or_else
です。
これは標準ライブラリでResult<T, E>
に定義されています。unwrap_or_else
を使うことで、
panic!
ではない何らか独自のエラー処理を定義できるのです。このResult
がOk
値だったら、
このメソッドの振る舞いはunwrap
に似ています: Ok
が包んでいる中身の値を返すのです。
しかし、値がErr
値なら、このメソッドは、クロージャ内でコードを呼び出し、
クロージャは私たちが定義し、引数としてunwrap_or_else
に渡す匿名関数です。クロージャについては第13章で詳しく講義します。
とりあえず、unwrap_or_else
は、今回リスト12-9で追加したnot enough arguments
という静的文字列のErr
の中身を、
縦棒の間に出現するerr
引数のクロージャに渡していることだけ知っておく必要があります。
クロージャのコードはそれから、実行された時にerr
値を使用できます。
新規use
行を追加して標準ライブラリからprocess
をインポートしました。クロージャ内のエラー時に走るコードは、
たった2行です: err
の値を出力し、それからprocess::exit
を呼び出します。process::exit
関数は、
即座にプログラムを停止させ、渡された数字を終了コードとして返します。これは、リスト12-8で使用したpanic!
ベースの処理と似ていますが、
もう余計な出力はされません。試しましょう:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.48 secs
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
素晴らしい!この出力の方が遥かにユーザに優しいです。
main
からロジックを抽出する
これで設定解析のリファクタリングが終了したので、プログラムのロジックに目を向けましょう。
「バイナリプロジェクトの責任の分離」で述べたように、
現在main
関数に存在する設定のセットアップやエラー処理に関わらない全てのロジックを保持することになるrun
という関数を抽出します。
やり終わったら、main
は簡潔かつ視察で確かめやすくなり、他のロジック全部に対してテストを書くことができるでしょう。
リスト12-11は、抜き出したrun
関数を示しています。今は少しずつ段階的に関数を抽出する改善を行っています。
それでも、src/main.rsに関数を定義していきます。
ファイル名: src/main.rs
fn main() {
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
run(config);
}
fn run(config: Config) {
let mut f = File::open(config.filename).expect("file not found");
let mut contents = String::new();
f.read_to_string(&mut contents)
.expect("something went wrong reading the file");
println!("With text:\n{}", contents);
}
// --snip--
これでrun
関数は、ファイル読み込みから始まるmain
関数の残りのロジック全てを含むようになりました。
このrun
関数は、引数にConfig
インスタンスを取ります。
run
関数からエラーを返す
残りのプログラムロジックがrun
関数に隔離されたので、リスト12-9のConfig::new
のように、
エラー処理を改善することができます。expect
を呼び出してプログラムにパニックさせる代わりに、
run
関数は、何か問題が起きた時にResult<T, E>
を返します。これにより、
さらにエラー処理周りのロジックをユーザに優しい形でmain
に統合することができます。
リスト12-12にシグニチャとrun
本体に必要な変更を示しています。
ファイル名: src/main.rs
use std::error::Error;
// --snip--
fn run(config: Config) -> Result<(), Box<Error>> {
let mut f = File::open(config.filename)?;
let mut contents = String::new();
f.read_to_string(&mut contents)?;
println!("With text:\n{}", contents);
Ok(())
}
ここでは、3つの大きな変更を行いました。まず、run
関数の戻り値をResult<(), Box<Error>>
に変えました。
この関数は、以前はユニット型、()
を返していて、それをOk
の場合に返される値として残しました。
エラー型については、トレイトオブジェクトのBox<Error>
を使用しました(同時に冒頭でuse
文により、
std::error::Error
をスコープに導入しています)。トレイトオブジェクトについては、第17章で講義します。
とりあえず、Box<Error>
は、関数がError
トレイトを実装する型を返すことを意味しますが、
戻り値の型を具体的に指定しなくても良いことを知っておいてください。これにより、
エラーケースによって異なる型のエラー値を返す柔軟性を得ます。
2番目に、expect
の呼び出しよりも?
演算子を選択して取り除きました。第9章で語りましたね。
エラーでパニックするのではなく、?
演算子は呼び出し元が処理できるように、現在の関数からエラー値を返します。
3番目に、run
関数は今、成功時にOk
値を返すようになりました。run
関数の成功型は、
シグニチャで()
と定義したので、ユニット型の値をOk
値に包む必要があります。
最初は、このOk(())
という記法は奇妙に見えるかもしれませんが、このように()
を使うことは、
run
を副作用のためだけに呼び出していると示唆する慣習的な方法です; 必要な値は返しません。
このコードを実行すると、コンパイルは通るものの、警告が表示されるでしょう:
warning: unused `std::result::Result` which must be used
(警告: 使用されなければならない`std::result::Result`が未使用です)
--> src/main.rs:18:5
|
18 | run(config);
| ^^^^^^^^^^^^
= note: #[warn(unused_must_use)] on by default
コンパイラは、コードがResult
値を無視していると教えてくれて、このResult
値は、
エラーが発生したと示唆しているかもしれません。しかし、エラーがあったか確認するつもりはありませんが、
コンパイラは、ここにエラー処理コードを書くつもりだったんじゃないかと思い出させてくれています!
今、その問題を改修しましょう。
main
でrun
から返ってきたエラーを処理する
リスト12-10のConfig::new
に対して行った方法に似たテクニックを使用してエラーを確認し、扱いますが、
少し違いがあります:
ファイル名: src/main.rs
fn main() {
// --snip--
println!("Searching for {}", config.query);
println!("In file {}", config.filename);
if let Err(e) = run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
unwrap_or_else
ではなく、if let
でrun
がErr
値を返したかどうかを確認し、そうならprocess::exit(1)
を呼び出しています。
run
関数は、Config::new
がConfig
インスタンスを返すのと同じようにunwrap
したい値を返すことはありません。
run
は成功時に()
を返すので、エラーを検知することにのみ興味があり、()
でしかないので、
unwrap_or_else
に包まれた値を返してもらう必要はないのです。
if let
とunwrap_or_else
関数の中身はどちらも同じです: エラーを出力して終了します。
コードをライブラリクレートに分割する
ここまでminigrep
は良さそうですね!では、テストを行え、src/main.rsファイルの責任が減らせるように、
src/main.rsファイルを分割し、一部のコードをsrc/lib.rsファイルに置きましょう。
main
関数以外のコード全部をsrc/main.rsからsrc/lib.rsに移動しましょう:
run
関数定義- 関係する
use
文 Config
の定義Config::new
関数定義
src/lib.rsの中身にはリスト12-13に示したようなシグニチャがあるはずです(関数の本体は簡潔性のために省略しました)。 リスト12-14でsrc/main.rsに変更を加えるまで、このコードはコンパイルできないことに注意してください。
ファイル名: src/lib.rs
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
// --snip--
}
}
pub fn run(config: Config) -> Result<(), Box<Error>> {
// --snip--
}
ここでは、寛大にpub
を使用しています: Config
のフィールドとnew
メソッドとrun
関数です。
これでテスト可能な公開APIのあるライブラリクレートができました!
さて、src/lib.rsに移動したコードをsrc/main.rsのバイナリクレートのスコープに持っていく必要があります。 リスト12-14に示したようにですね。
ファイル名: src/main.rs
extern crate minigrep;
use std::env;
use std::process;
use minigrep::Config;
fn main() {
// --snip--
if let Err(e) = minigrep::run(config) {
// --snip--
}
}
ライブラリクレートをバイナリクレートに持っていくのに、extern crate minigrep
を使用しています。
それからuse minigrep::Config
行を追加してConfig
型をスコープに持ってきて、
run
関数にクレート名を接頭辞として付けます。これで全機能が連結され、動くはずです。
cargo run
でプログラムを走らせて、すべてがうまくいっていることを確かめてください。
ふう!作業量が多かったですね。ですが、将来成功する準備はできています。 もう、エラー処理は遥かに楽になり、コードのモジュール化もできました。 ここから先の作業は、ほぼsrc/lib.rsで完結するでしょう。
古いコードでは大変だけれども、新しいコードでは楽なことをして新発見のモジュール性を活用しましょう: テストを書くのです!