はじめに
モジューラリティとエラーハンドリングの改善のためのリファクタリングへようこそ。この実験は、Rust Bookの一部です。LabExでRustのスキルを練習することができます。
この実験では、タスクを分離し、構成変数をグループ化し、意味のあるエラーメッセージを提供し、エラーハンドリングコードを統合することで、プログラムをリファクタリングして、モジューラリティとエラーハンドリングを改善します。
This tutorial is from open-source community. Access the source code
💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください
モジューラリティとエラーハンドリングの改善のためのリファクタリングへようこそ。この実験は、Rust Bookの一部です。LabExでRustのスキルを練習することができます。
この実験では、タスクを分離し、構成変数をグループ化し、意味のあるエラーメッセージを提供し、エラーハンドリングコードを統合することで、プログラムをリファクタリングして、モジューラリティとエラーハンドリングを改善します。
プログラムを改善するために、プログラムの構造と潜在的なエラーの処理方法に関係する4つの問題を修正します。まず、main
関数は現在2つのタスクを実行しています。引数を解析し、ファイルを読み取ります。プログラムが拡大するにつれて、main
関数が処理する個別のタスクの数が増えます。関数が責任を担うようになると、推論しにくくなり、テストしにくくなり、一部を破壊することなく変更しにくくなります。機能を分離するのが最善で、各関数は1つのタスクに責任を持つようにします。
この問題はまた、2番目の問題にも関係しています。query
とfile_path
はプログラムの構成変数ですが、contents
のような変数はプログラムのロジックを実行するために使用されます。main
が長くなるほど、スコープに持ち込む必要のある変数が増えます。スコープ内にある変数が多いほど、各変数の目的を把握するのが難しくなります。構成変数を1つの構造体にグループ化して目的を明確にするのが最善です。
3番目の問題は、ファイルの読み取りに失敗したときにexpect
を使用してエラーメッセージを表示していますが、エラーメッセージはただShould have been able to read the file
と表示されます。ファイルの読み取りに失敗する場合がいくつかあります。たとえば、ファイルが存在しない場合や、それを開く権限がない場合などです。現在のところ、状況に関係なく、すべてに同じエラーメッセージを表示しており、ユーザーには何の情報も与えていません!
4番目の問題は、異なるエラーを処理するためにexpect
を繰り返し使用していることです。ユーザーが十分な引数を指定せずにプログラムを実行すると、Rustからindex out of bounds
エラーが表示されますが、これは問題を明確に説明していません。エラーハンドリングロジックを変更する必要がある場合、すべてのエラーハンドリングコードを1か所にまとめておくと、将来の保守担当者がコードを参照する場所が1か所だけになるため最適です。すべてのエラーハンドリングコードを1か所にまとめることで、最終ユーザーにとって意味のあるメッセージを表示することも保証されます。
これらの4つの問題に対処するために、プロジェクトをリファクタリングしましょう。
複数のタスクの責任をmain
関数に割り当てるという組織的な問題は、多くのバイナリプロジェクトに共通しています。その結果、Rustコミュニティは、main
が大きくなり始めたときにバイナリプログラムの個別の関心事を分割するためのガイドラインを開発しました。このプロセスには次のステップがあります。
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は、main
の新しい開始部分を示しており、新しい関数parse_config
を呼び出しています。この関数は、当面はsrc/main.rs
で定義します。
ファイル名: src/main.rs
fn main() {
let args: Vec<String> = env::args().collect();
let (query, file_path) = parse_config(&args);
--snip--
}
fn parse_config(args: &[String]) -> (&str, &str) {
let query = &args[1];
let file_path = &args[2];
(query, file_path)
}
リスト12-5: main
からparse_config
関数を抽出する
コマンドライン引数をまだベクトルに収集していますが、main
関数内でインデックス1の引数値をquery
変数に、インデックス2の引数値をfile_path
変数に代入する代わりに、ベクトル全体をparse_config
関数に渡します。そしてparse_config
関数が、どの引数がどの変数に入るかを決定するロジックを保持し、値をmain
に戻します。main
内では依然としてquery
とfile_path
変数を作成しますが、コマンドライン引数と変数がどのように対応するかを決定する責任はmain
にはもはやありません。
この再構築は、私たちの小さなプログラムにとってはやりすぎかのように見えるかもしれませんが、小さな段階で徐々にリファクタリングしています。この変更を行った後、再度プログラムを実行して、引数解析がまだ機能することを確認しましょう。問題が発生したときに原因を特定するのに役立つように、頻繁に進捗状況を確認するのは良いことです。
parse_config
関数をさらに改善するために、もう少し小さなステップを踏みましょう。現在、タプルを返していますが、その後すぐにそのタプルを再び個々の部分に分割しています。これは、まだ正しい抽象化ができていないことの兆候です。
改善の余地があることを示すもう一つの指標は、parse_config
のconfig
部分です。これは、返す2つの値が関連しており、両方とも1つの構成値の一部であることを意味します。現在、データの構造では、2つの値をタプルにグループ化する以外に、この意味を伝えていません。代わりに、2つの値を1つの構造体に入れ、構造体の各フィールドに意味のある名前を付けます。これにより、このコードの将来の保守担当者が、異なる値がどのように関連しているか、そしてそれらの目的が何かを理解するのが容易になります。
リスト12-6は、parse_config
関数の改善点を示しています。
ファイル名: src/main.rs
fn main() {
let args: Vec<String> = env::args().collect();
1 let config = parse_config(&args);
println!("Searching for {}", 2 config.query);
println!("In file {}", 3 config.file_path);
let contents = fs::read_to_string(4 config.file_path)
.expect("Should have been able to read the file");
--snip--
}
5 struct Config {
query: String,
file_path: String,
}
6 fn parse_config(args: &[String]) -> Config {
7 let query = args[1].clone();
8 let file_path = args[2].clone();
Config { query, file_path }
}
リスト12-6: parse_config
を再構築してConfig
構造体のインスタンスを返す
query
とfile_path
という名前のフィールドを持つように定義されたConfig
という名前の構造体を追加しました[5]。parse_config
のシグネチャは、現在、Config
値を返すことを示しています[6]。parse_config
の本体では、以前はargs
内のString
値を参照する文字列スライスを返していましたが、今ではConfig
を定義して所有するString
値を含めるようにしました。main
内のargs
変数は引数値の所有者であり、parse_config
関数にのみ借りて使用させています。これは、Config
がargs
内の値の所有権を取得しようとすると、Rustの借用規則に違反することを意味します。
String
データを管理する方法はいくつかありますが、最も簡単ですが多少非効率的な方法は、値に対してclone
メソッドを呼び出すことです[7] [8]。これにより、Config
インスタンスが所有するデータの完全なコピーが作成され、文字列データへの参照を格納するよりも時間とメモリが多く必要になります。ただし、データをクローンすることでコードが非常に単純になります。なぜなら、参照の生存時間を管理する必要がないからです。このような状況では、単純さを得るために少しのパフォーマンスを犠牲にするのは価値のあるトレードオフです。
cloneを使用するトレードオフ
多くのRustプログラマーは、実行時のコストのために、所有権の問題を解決するために
clone
を使用することを避ける傾向があります。13章では、この種の状況でより効率的な方法を学びます。しかし、今のところ、進歩を続けるためにいくつかの文字列をコピーすることは大丈夫です。なぜなら、これらのコピーは一度だけ行い、ファイルパスとクエリ文字列は非常に小さいからです。最初の段階でコードを超最適化しようとするよりも、少し非効率的な稼働プログラムを持つ方が良いです。Rustに慣れるにつれて、最も効率的なソリューションから始めることが容易になりますが、今のところ、clone
を呼び出すことは完全に許容されます。
main
を更新して、parse_config
が返すConfig
のインスタンスをconfig
という名前の変数に格納し[1]、以前は個別のquery
とfile_path
変数を使用していたコードを更新して、現在は代わりにConfig
構造体のフィールドを使用するようにしました[2] [3] [4]。
今では、コードがより明確に伝えているのは、query
とfile_path
が関連しており、それらの目的はプログラムの動作方法を構成することであるということです。これらの値を使用するコードは、目的に応じた名前のフィールドのconfig
インスタンスからそれらを見つけることを知っています。
これまで、コマンドライン引数を解析する責任のあるロジックをmain
から抽出し、parse_config
関数に配置しました。これにより、query
とfile_path
の値が関連していることがわかり、その関係をコードで表現する必要があることがわかりました。そこで、query
とfile_path
の関連する目的を名前付けするためにConfig
構造体を追加し、parse_config
関数から値の名前を構造体フィールド名として返せるようにしました。
ですから、今ではparse_config
関数の目的がConfig
インスタンスを作成することになっているので、parse_config
を単なる関数からConfig
構造体に関連付けられたnew
という名前の関数に変更することができます。この変更により、コードがより慣例に沿ったものになります。標準ライブラリの型(たとえばString
)のインスタンスを作成するには、String::new
を呼び出します。同様に、parse_config
をConfig
に関連付けられたnew
関数に変更することで、Config::new
を呼び出すことでConfig
のインスタンスを作成できるようになります。リスト12-7は必要な変更を示しています。
ファイル名: src/main.rs
fn main() {
let args: Vec<String> = env::args().collect();
1 let config = Config::new(&args);
--snip--
}
--snip--
2 impl Config {
3 fn new(args: &[String]) -> Config {
let query = args[1].clone();
let file_path = args[2].clone();
Config { query, file_path }
}
}
リスト12-7: parse_config
をConfig::new
に変更する
main
を更新して、parse_config
を呼び出していた部分をConfig::new
を呼び出すように変更しました[1]。parse_config
の名前をnew
に変更し[3]、impl
ブロック内に移動しました[2]。これにより、new
関数がConfig
と関連付けられます。このコードを再コンパイルして、正常に動作することを確認してみてください。
次に、エラーハンドリングを修正しましょう。args
ベクトルのインデックス1またはインデックス2の値にアクセスしようとすると、ベクトルに3つ未満の要素が含まれている場合、プログラムがパニックになります。引数なしでプログラムを実行してみてください。出力は次のようになります。
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but
the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display
a backtrace
index out of bounds: the len is 1 but the index is 1
という行は、プログラマー向けのエラーメッセージです。最終ユーザーにとって、代わりに何をすべきかを理解するのに役立ちません。今すぐそれを修正しましょう。
リスト12-8では、new
関数にチェックを追加して、インデックス1とインデックス2にアクセスする前にスライスが十分に長いことを確認します。スライスが十分に長くなければ、プログラムはパニックになり、より良いエラーメッセージが表示されます。
ファイル名: src/main.rs
--snip--
fn new(args: &[String]) -> Config {
if args.len() < 3 {
panic!("not enough arguments");
}
--snip--
リスト12-8: 引数の数のチェックを追加する
このコードは、リスト9-13で書いたGuess::new
関数に似ています。そこでは、value
引数が有効な値の範囲外の場合にpanic!
を呼び出していました。ここでは値の範囲をチェックする代わりに、args
の長さが少なくとも3
であることをチェックし、この条件が満たされているという前提のもとで関数の残りの部分を動作させます。args
に3つ未満の要素が含まれている場合、この条件はtrue
になり、panic!
マクロを呼び出してプログラムを即座に終了します。
new
にこれらの数行のコードを追加したので、もう一度引数なしでプログラムを実行して、今のエラーの様子を見てみましょう。
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments',
src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display
a backtrace
この出力は良くなりました。今では合理的なエラーメッセージがあります。ただし、ユーザーに与える必要のない余分な情報もあります。おそらく、リスト9-13で使用した手法はここでは最適なものではないかもしれません。第9章で説明したように、panic!
の呼び出しは、使用問題よりもプログラミング上の問題に対してより適切です。代わりに、第9章で学んだもう一つの手法を使用します。つまり、成功またはエラーを示すResult
を返すことです。
代わりに、Result
値を返します。成功した場合にはConfig
インスタンスを含み、エラーの場合には問題を説明します。また、関数名をnew
からbuild
に変更します。なぜなら、多くのプログラマーがnew
関数は決して失敗しないことを期待しているからです。Config::build
がmain
に通信する際、Result
型を使って問題があることを知らせることができます。そして、main
を変更して、Err
バリアントをユーザーにとってより実用的なエラーに変換します。panic!
の呼び出しが引き起こすthread'main'
やRUST_BACKTRACE
に関する周辺のテキストなしです。
リスト12-9は、現在Config::build
と呼び出している関数の返り値と、Result
を返すために必要な関数の本体に対して行う変更を示しています。ただし、main
も更新しない限りコンパイルされません。次のリストでそれを行います。
ファイル名: src/main.rs
impl Config {
fn build(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
Ok(Config { query, file_path })
}
}
リスト12-9: Config::build
からResult
を返す
build
関数は、成功した場合にはConfig
インスタンスを含むResult
を返し、エラーの場合には&'static str
を返します。エラー値は常に'static
寿命を持つ文字列リテラルになります。
関数の本体で2つの変更を行いました。ユーザーが十分な引数を渡さない場合にpanic!
を呼び出す代わりに、今ではErr
値を返し、Config
の返り値をOk
でラップしました。これらの変更により、関数が新しい型シグネチャに準拠するようになりました。
Config::build
からErr
値を返すことで、main
関数はbuild
関数から返されるResult
値を処理し、エラーの場合に処理をよりきれいに終了させることができます。
エラーケースを処理してユーザーに親切なメッセージを表示するには、main
を更新してConfig::build
が返すResult
を処理する必要があります。これはリスト12-10に示されています。また、コマンドラインツールを非ゼロのエラーコードで終了させる責任をpanic!
から外し、代わりに手動で実装します。非ゼロの終了ステータスは、プログラムがエラー状態で終了したことを呼び出し元のプロセスに知らせるための慣例です。
ファイル名: src/main.rs
1 use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
2 let config = Config::build(&args).3 unwrap_or_else(|4 err| {
5 println!("Problem parsing arguments: {err}");
6 process::exit(1);
});
--snip--
リスト12-10: Config
の構築に失敗した場合にエラーコードで終了する
このリストでは、まだ詳細を説明していないメソッドを使用しています。unwrap_or_else
です。これは、標準ライブラリによってResult<T, E>
に定義されています[2]。unwrap_or_else
を使用することで、panic!
ではないカスタムのエラーハンドリングを定義できます。Result
がOk
値の場合、このメソッドの動作はunwrap
と似ています。つまり、Ok
がラップしている内部値を返します。ただし、値がErr
値の場合、このメソッドはクロージャ内のコードを呼び出します。クロージャは、定義してunwrap_or_else
に引数として渡す匿名関数です[3]。第13章でクロージャについてもっと詳しく説明します。今のところ、unwrap_or_else
がErr
の内部値を渡すことだけを知っておけば十分です。この場合、それはリスト12-9で追加した静的文字列"not enough arguments"
で、縦棒の間に表示される引数err
のクロージャに渡されます[4]。その後、クロージャ内のコードは実行時にerr
値を使用できます。
標準ライブラリからprocess
をスコープ内に持ち込むために新しいuse
行を追加しました[1]。エラーケースで実行されるクロージャ内のコードは2行だけです。err
値を表示し[5]、その後process::exit
を呼び出します[6]。process::exit
関数は、プログラムを即座に停止し、終了ステータスコードとして渡された数を返します。これは、リスト12-8で使用したpanic!
ベースのハンドリングに似ていますが、もはや余分な出力は得られません。試してみましょう。
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
素晴らしい!この出力はユーザーにとってははるかに親切です。
コンフィグの解析のリファクタリングが完了したので、次にプログラムのロジックに目を向けましょう。「バイナリプロジェクトの懸念事項の分離」で述べたように、run
という名前の関数を抽出します。この関数には、現在main
関数に含まれているコンフィグの設定やエラーの処理に関係のないすべてのロジックを保持させます。完了したら、main
は簡潔になり、検査によって容易に検証できるようになります。そして、他のすべてのロジックに対してテストを書くことができるようになります。
リスト12-11に抽出したrun
関数を示します。今のところ、関数を抽出するという小さな段階的な改善を行っています。まだsrc/main.rs
に関数を定義しています。
ファイル名: src/main.rs
fn main() {
--snip--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
run(config);
}
fn run(config: Config) {
let contents = fs::read_to_string(config.file_path)
.expect("Should have been able to read the file");
println!("With text:\n{contents}");
}
--snip--
リスト12-11: プログラムの残りのロジックを含むrun
関数を抽出する
run
関数には、今やmain
から残ったすべてのロジックが含まれています。ファイルの読み込みから始まります。run
関数はConfig
インスタン스를引数として受け取ります。
残りのプログラムロジックをrun
関数に分離することで、エラーハンドリングを改善することができます。これは、リスト12-9でConfig::build
を行ったようにです。何か問題が起こったときにexpect
を呼び出すことでプログラムをパニックにさせる代わりに、run
関数はエラーが発生したときにResult<T, E>
を返します。これにより、エラー処理をユーザーにとって親切な方法でmain
にさらに統合することができます。リスト12-12には、run
のシグネチャと本体に対して行う必要がある変更を示しています。
ファイル名: src/main.rs
1 use std::error::Error;
--snip--
2 fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)3?;
println!("With text:\n{contents}");
4 Ok(())
}
リスト12-12: Result
を返すようにrun
関数を変更する
ここでは3つの大きな変更を行っています。まず、run
関数の返却型をResult<(), Box<dyn Error>>
に変更しました[2]。この関数は以前、ユニット型()
を返していましたが、Ok
の場合に返される値としてこれを維持しています。
エラー型については、トレイトオブジェクトBox<dyn Error>
を使用しました(トップのuse
文でstd::error::Error
をスコープ内に持ち込んでいます[1])。第17章でトレイトオブジェクトについて説明します。今のところ、Box<dyn Error>
は関数がError
トレイトを実装する型を返すことを意味するだけで、返り値がどのような特定の型であるかを指定する必要はありません。これにより、異なるエラーケースで異なる型のエラー値を返す柔軟性が得られます。dyn
キーワードは「動的」の略です。
第二に、第9章で説明したように、expect
の呼び出しを?
演算子に置き換えました[3]。エラーが発生したときにpanic!
する代わりに、?
は呼び出し元が処理するために現在の関数からエラー値を返します。
第三に、run
関数は成功した場合にOk
値を返します[4]。シグネチャでrun
関数の成功型を()
と宣言しているため、ユニット型の値をOk
値でラップする必要があります。このOk(())
構文は最初は少々奇妙に見えるかもしれませんが、このように()
を使用するのは、副作用のためだけにrun
を呼び出していることを示す慣用的な方法です。返り値は必要なものではありません。
このコードを実行すると、コンパイルは成功しますが、警告が表示されます。
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be
handled
Rustは、コードがResult
値を無視しており、Result
値がエラーが発生したことを示す可能性があることを教えてくれます。しかし、エラーが発生したかどうかを確認しておらず、コンパイラがここにエラー処理コードが必要だったはずだと思い返させてくれます! では、この問題を解決しましょう。
エラーをチェックして、リスト12-10でConfig::build
に対して使用したものと同様の手法を使って処理しますが、少し違いがあります。
ファイル名: src/main.rs
fn main() {
--snip--
println!("Searching for {}", config.query);
println!("In file {}", config.file_path);
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
run
がErr
値を返すかどうかをチェックし、返した場合にprocess::exit(1)
を呼び出すために、unwrap_or_else
ではなくif let
を使用します。run
関数は、Config::build
がConfig
インスタンスを返すのと同じように、unwrap
したい値を返しません。run
は成功した場合に()
を返すため、エラーを検出することだけに関心があります。したがって、unwrap_or_else
が展開された値(これはただの()
になります)を返す必要はありません。
if let
とunwrap_or_else
関数の本体はどちらの場合も同じです。エラーを表示して終了します。
これまでのところ、私たちのminigrep
プロジェクトは順調に進んでいます! ここでは、src/main.rs
ファイルを分割し、一部のコードをsrc/lib.rs
ファイルに移動します。これにより、コードをテストできるようになり、責任が少ないsrc/main.rs
ファイルを持つことができます。
src/main.rs
にあるmain
関数に含まれていないすべてのコードをsrc/lib.rs
に移動しましょう。
run
関数の定義use
文Config
の定義Config::build
関数の定義src/lib.rs
の内容は、リスト12-13に示すシグネチャになるはずです(簡略化のため、関数の本体は省略しています)。これは、リスト12-14でsrc/main.rs
を修正するまではコンパイルされません。
ファイル名: src/lib.rs
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub file_path: String,
}
impl Config {
pub fn build(
args: &[String],
) -> Result<Config, &'static str> {
--snip--
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
--snip--
}
リスト12-13: Config
とrun
をsrc/lib.rs
に移動する
pub
キーワードを頻繁に使用しています。Config
、そのフィールドとbuild
メソッド、およびrun
関数に対してです。これで、テストできるパブリックAPIを持つライブラリクレートができました!
次に、src/lib.rs
に移動したコードをsrc/main.rs
のバイナリクレートのスコープに持ち込む必要があります。これは、リスト12-14に示すようになります。
ファイル名: src/main.rs
use std::env;
use std::process;
use minigrep::Config;
fn main() {
--snip--
if let Err(e) = minigrep::run(config) {
--snip--
}
}
リスト12-14: src/main.rs
でminigrep
ライブラリクレートを使用する
use minigrep::Config
行を追加して、ライブラリクレートからConfig
型をバイナリクレートのスコープに持ち込み、run
関数にクレート名を接頭辞として付けます。これで、すべての機能が接続され、正常に動作するはずです。cargo run
でプログラムを実行し、すべてが正しく動作することを確認しましょう。
えーっと! 大変な作業でしたが、これで将来の成功のための基盤が整いました。今ではエラー処理がはるかに簡単になり、コードもよりモジュール化されています。これ以降、ほとんどの作業はsrc/lib.rs
で行われます。
この新しいモジューラリティを生かして、古いコードでは難しかったことを新しいコードでは簡単にできることを行いましょう。テストを書きます!
おめでとうございます! モジューラリティとエラーハンドリングの改善のためのリファクタリングの実験を完了しました。LabExでさらに多くの実験を行って、スキルを向上させることができます。