I/O プロジェクトの改善
イテレータに関するこの新しい知識により、第12章の I/O プロジェクトを改善できます。イテレータを使うことで、コード中のいくつかの箇所をより明確かつ簡潔にできるのです。イテレータによって Config::build 関数と search 関数の実装をどのように改善できるかを見ていきましょう。
イテレータを使って clone を取り除く
リスト 12-6 では、String 値のスライスを受け取り、そのスライスにインデックスでアクセスして値をクローンすることで Config 構造体のインスタンスを作成するコードを追加しました。これにより、Config 構造体がそれらの値を所有できるようにしていました。リスト 13-17 では、リスト 12-23 にあった Config::build 関数の実装を再掲しています。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
println!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
当時は、効率の悪い clone 呼び出しは将来取り除くので気にしなくてよいと言いました。では、その時が来ました!
ここで clone が必要だったのは、引数 args が String 要素を持つスライスであり、build 関数は args を所有していないからです。Config インスタンスの所有権を返すためには、Config インスタンスがその値を所有できるように、query フィールドと file_path フィールドに入れる値をクローンする必要がありました。
イテレータについての新しい知識により、build 関数を、スライスを借用する代わりにイテレータの所有権を引数として受け取るように変更できます。スライスの長さを確認して特定の位置にインデックスでアクセスするコードの代わりに、イテレータの機能を使います。これにより、イテレータが値にアクセスするため、Config::build 関数が何をしているのかがより明確になります。
Config::build がイテレータの所有権を受け取り、借用を伴うインデックス操作をやめれば、clone を呼び出して新たにメモリ確保を行う代わりに、イテレータから String の値を Config にムーブできるようになります。
返されたイテレータを直接使う
I/O プロジェクトの src/main.rs ファイルを開いてください。内容は次のようになっているはずです。
ファイル名: src/main.rs
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::build(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
// --snip--
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
まず、リスト 12-24 にあった main 関数の冒頭を、今回はイテレータを使うリスト 13-18 のコードに変更します。Config::build も更新するまでは、これはコンパイルされません。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
// --snip--
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
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();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
env::args 関数はイテレータを返します! イテレータの値をベクタに集めてからそのスライスを Config::build に渡すのではなく、今度は env::args から返されたイテレータの所有権を直接 Config::build に渡します。
次に、Config::build の定義を更新する必要があります。Config::build のシグネチャをリスト 13-19 のように変更しましょう。関数本体も更新する必要があるので、これもまだコンパイルされません。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(
mut args: impl Iterator<Item = String>,
) -> Result<Config, &'static str> {
// --snip--
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let file_path = args[2].clone();
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
標準ライブラリの env::args 関数のドキュメントによると、返されるイテレータの型は std::env::Args であり、この型は Iterator トレイトを実装して String 値を返します。
Config::build 関数のシグネチャを更新し、パラメータ args が &[String] ではなく、トレイト境界 impl Iterator<Item = String> を持つジェネリック型になるようにしました。第10章の 「トレイトを引数として使う」
節で説明した impl Trait 構文のこの使い方は、args が Iterator トレイトを実装し、String 項目を返す任意の型になり得ることを意味します。
args の所有権を受け取り、さらに反復処理によって args を変更するので、args パラメータの指定に mut キーワードを追加して可変にできます。
Iterator トレイトのメソッドを使う
次に、Config::build の本体を修正します。args は Iterator トレイトを実装しているので、next メソッドを呼び出せると分かります! リスト 13-20 では、next メソッドを使うようにリスト 12-23 のコードを更新しています。
use std::env;
use std::error::Error;
use std::fs;
use std::process;
use minigrep::{search, search_case_insensitive};
fn main() {
let config = Config::build(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
pub struct Config {
pub query: String,
pub file_path: String,
pub ignore_case: bool,
}
impl Config {
fn build(
mut args: impl Iterator<Item = String>,
) -> Result<Config, &'static str> {
args.next();
let query = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a query string"),
};
let file_path = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a file path"),
};
let ignore_case = env::var("IGNORE_CASE").is_ok();
Ok(Config {
query,
file_path,
ignore_case,
})
}
}
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.file_path)?;
let results = if config.ignore_case {
search_case_insensitive(&config.query, &contents)
} else {
search(&config.query, &contents)
};
for line in results {
println!("{line}");
}
Ok(())
}
env::args の戻り値の最初の値はプログラム名であることを思い出してください。これを無視して次の値に進みたいので、まず next を呼び出し、その戻り値には何もしません。次に、Config の query フィールドに入れたい値を取得するために next を呼び出します。next が Some を返した場合は、match を使って値を取り出します。None を返した場合は、引数が十分に与えられていないことを意味するので、Err 値を返して早期に戻ります。file_path の値についても同じことを行います。
イテレータアダプタでコードをより明確にする
I/O プロジェクトの search 関数でもイテレータを活用できます。この関数は、リスト 12-19 のものをリスト 13-21 に再掲しています。
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));
}
}
このコードは、イテレータアダプタメソッドを使うことで、より簡潔に書けます。そうすることで、可変の中間 results ベクタを持たずに済みます。関数型プログラミングのスタイルでは、コードをより明確にするために可変状態の量を最小限に抑えることが好まれます。可変状態を取り除くことで、将来的に検索を並列に実行する改良が可能になるかもしれません。というのも、results ベクタへの同時アクセスを管理する必要がなくなるからです。リスト 13-22 はこの変更を示しています。
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents
.lines()
.filter(|line| line.contains(query))
.collect()
}
pub fn search_case_insensitive<'a>(
query: &str,
contents: &'a str,
) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn case_sensitive() {
let query = "duct";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";
assert_eq!(
vec!["Rust:", "Trust me."],
search_case_insensitive(query, contents)
);
}
}
さらに改善するには、collect の呼び出しを削除し、戻り値の型を impl Iterator<Item = &'a str> に変更して、search 関数からイテレータを返すようにします。そうすると、この関数はイテレータアダプタになります。テストも更新する必要があることに注意してください! この変更を行う前後で minigrep ツールを使って大きなファイルを検索し、動作の違いを観察してください。この変更前は、すべての結果を集め終わるまでプログラムは結果をまったく表示しませんが、変更後は、一致する行が見つかるたびに結果が表示されます。これは、run 関数内の for ループがイテレータの遅延性を活用できるようになるためです。
ループとイテレータのどちらを選ぶか
次に自然に出てくる疑問は、自分のコードではどちらのスタイルを選ぶべきか、そしてなぜかということです。つまり、リスト13-21の元の実装と、リスト13-22のイテレータを使ったバージョンのどちらを選ぶべきかです(イテレータ自体を返すのではなく、返す前にすべての結果を集めると仮定した場合)。ほとんどのRustプログラマは、イテレータスタイルを使うことを好みます。最初は少し慣れるのが大変ですが、さまざまなイテレータアダプタとその働きの感覚をつかめば、イテレータのほうが理解しやすくなることがあります。ループのさまざまな細部を扱ったり、新しいベクタを組み立てたりする代わりに、コードはループの高レベルな目的に集中します。これにより、ありふれたコードの一部が抽象化されるため、イテレータ内の各要素が満たさなければならないフィルタ条件のように、このコードに固有の概念が見えやすくなります。
しかし、この2つの実装は本当に等価なのでしょうか? 直感的には、より低レベルなループのほうが速いと思うかもしれません。パフォーマンスについて話しましょう。