テスト駆動開発で機能を追加する
これで検索ロジックを src/lib.rs の main 関数から分離できたので、コードの中核となる機能に対するテストをずっと簡単に書けるようになりました。コマンドラインからバイナリを呼び出さなくても、さまざまな引数で関数を直接呼び出し、戻り値を確認できます。
このセクションでは、次の手順に従うテスト駆動開発(TDD)のプロセスを使って、minigrep プログラムに検索ロジックを追加します。
- 失敗するテストを書き、期待した理由で失敗することを確認するためにそれを実行する。
- 新しいテストが通るようにするために必要な最小限のコードだけを書く、または修正する。
- 追加または変更したコードをリファクタリングし、テストが引き続き通ることを確認する。
- 手順 1 から繰り返す!
これはソフトウェアを書く多くの方法のうちの 1 つにすぎませんが、TDD はコード設計を導くのに役立ちます。テストを通すコードを書く前にテストを書くことで、プロセス全体を通して高いテストカバレッジを維持しやすくなります。
ここでは、ファイル内容の中からクエリ文字列を実際に検索し、クエリに一致した行の一覧を生成する機能の実装をテスト駆動で進めます。この機能は search という関数に追加します。
失敗するテストを書く
src/lib.rs では、第11章 で行ったように、テスト関数を持つ tests モジュールを追加します。テスト関数では、search 関数に持たせたい振る舞いを指定します。つまり、クエリと検索対象のテキストを受け取り、クエリを含むテキスト中の行だけを返すようにします。リスト 12-15 にこのテストを示します。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
unimplemented!();
}
// --snip--
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
このテストでは文字列 "duct" を検索します。検索対象のテキストは 3 行あり、そのうち "duct" を含むのは 1 行だけです(先頭の二重引用符の直後にあるバックスラッシュは、この文字列リテラルの内容の先頭に Rust が改行文字を入れないようにするものです)。search 関数から返される値が、期待するその 1 行だけを含んでいることをアサートします。
このテストを実行すると、現在は unimplemented! マクロが “not implemented” というメッセージでパニックを起こすため失敗します。TDD の原則に従い、まずは小さな一歩として、search 関数を常に空のベクタを返すように定義することで、その関数を呼び出したときにテストがパニックしないようにするための最小限のコードだけを追加します。これはリスト 12-16 に示しています。すると、テストはコンパイルできるようになり、空のベクタが "safe, fast, productive." という行を含むベクタと一致しないため、失敗するはずです。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
では、なぜ search のシグネチャに明示的なライフタイム 'a を定義し、そのライフタイムを contents 引数と戻り値に使う必要があるのかを説明しましょう。第10章 で見たように、ライフタイムパラメータは、どの引数のライフタイムが戻り値のライフタイムと結び付いているかを指定します。この場合、返されるベクタには、contents 引数のスライスを参照する文字列スライスが含まれるべきであることを示しています(query 引数のスライスではありません)。
言い換えると、search 関数が返すデータは、search 関数に contents 引数として渡されたデータと同じだけ生存すると Rust に伝えているのです。これは重要です! スライスが参照しているデータは、その参照が有効であるために有効でなければなりません。もしコンパイラが、contents ではなく query の文字列スライスを作っていると仮定すると、安全性チェックを誤って行うことになります。
もしライフタイム注釈を付け忘れてこの関数をコンパイルしようとすると、次のエラーが出ます。
$ cargo build
Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
--> src/lib.rs:1:51
|
1 | pub fn search(query: &str, contents: &str) -> Vec<&str> {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `query` or `contents`
help: consider introducing a named lifetime parameter
|
1 | pub fn search<'a>(query: &'a str, contents: &'a str) -> Vec<&'a str> {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` (lib) due to 1 previous error
Rust には、出力に対して 2 つのパラメータのうちどちらが必要なのかわからないため、明示的に伝える必要があります。ヘルプテキストでは、すべてのパラメータと出力型に同じライフタイムパラメータを指定するよう提案されていますが、これは誤りであることに注意してください! contents は私たちのすべてのテキストを含んでいるパラメータであり、そのテキストのうち一致した部分を返したいので、ライフタイム構文を使って戻り値と結び付けるべきなのは contents だけだとわかります。
他のプログラミング言語では、シグネチャ内で引数と戻り値を結び付けることを要求しませんが、このやり方にも時間とともに慣れていくでしょう。この例を、第10章の 「ライフタイムで参照を検証する」 セクションの例と比べてみるとよいかもしれません。
テストを通すコードを書く
現在、テストが失敗しているのは、常に空のベクタを返しているからです。これを修正して search を実装するために、プログラムは次の手順に従う必要があります。
- 内容の各行を順にたどる。
- その行にクエリ文字列が含まれているかを確認する。
- 含まれていれば、返す値の一覧に追加する。
- 含まれていなければ、何もしない。
- 一致した結果の一覧を返す。
では、最初に各行を順にたどるところから、各手順を見ていきましょう。
lines メソッドで各行を順にたどる
Rust には、文字列を 1 行ずつ反復処理するための便利なメソッドがあり、名前もそのまま lines です。これはリスト 12-17 に示すように動作します。なお、これはまだコンパイルできません。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
// do something with line
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
lines メソッドはイテレータを返します。イテレータについては 第13章 で詳しく扱います。しかし、コレクションの各要素に対して何らかのコードを実行するために、イテレータを使った for ループを リスト 3-5 で使っていたことを思い出してください。
各行にクエリが含まれているかを調べる
次に、現在の行にクエリ文字列が含まれているかどうかを調べます。ありがたいことに、文字列にはこれをしてくれる contains という便利なメソッドがあります! リスト 12-18 に示すように、search 関数に contains メソッドの呼び出しを追加しましょう。なお、これもまだコンパイルできません。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
for line in contents.lines() {
if line.contains(query) {
// do something with line
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
現時点では、機能を少しずつ積み上げています。コードをコンパイルできるようにするには、関数シグネチャで示したとおり、本体から値を返す必要があります。
一致した行の保存
この関数を完成させるには、返したい一致した行を保存する方法が必要です。
そのために、for ループの前で可変ベクターを作成し、push
メソッドを呼び出してベクターに line を保存できます。for
ループの後で、リスト 12-19 に示すように、そのベクターを返します。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
}
これで search 関数は query を含む行だけを返すはずであり、
テストも通るはずです。テストを実行してみましょう。
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
テストは通りました。これで、うまく動作することがわかります。
この時点で、テストを通したまま同じ機能を維持しつつ、search 関数の実装をリファクタリングできる箇所を検討できます。search 関数のコードはそれほど悪くありませんが、イテレーターの便利な機能をいくつか活用できていません。この例には 第13章で戻り、そこでイテレーターを詳しく探りながら、 これをどのように改善できるかを見ていきます。
これでプログラム全体が動作するはずです! 実際に試してみましょう。まずは、 エミリー・ディキンソンの詩からちょうど1行だけ返るはずの単語、frog です。
$ cargo run -- frog poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/minigrep frog poem.txt`
How public, like a frog
いいですね! では次に、body のように複数の行に一致する単語を試してみましょう。
$ cargo run -- body poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!
最後に、monomorphization のように、詩のどこにも存在しない単語を検索したときには、どの行も取得されないことを確認しましょう。
$ cargo run -- monomorphization poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep monomorphization poem.txt`
すばらしいです! 私たちは古典的なツールのミニ版を自分たちで作り、 アプリケーションをどのように構成するかについて多くを学びました。また、 ファイルの入力と出力、ライフタイム、テスト、コマンドライン解析についても少し学びました。
このプロジェクトの締めくくりとして、環境変数の扱い方と標準エラーへの出力方法を簡単に示します。どちらも、 コマンドラインプログラムを書くときに役立ちます。