rustを学んで⑤

·
#CLI#Rust#Terminal#rustlings

Rustの学習記録:テスト、イテレータ、ミニgrepアプリで学んだこと

今日はRustのテスト機能とミニgrepアプリの実装を通じて、多くの重要な概念を学びました。躓いた点を中心に、詳しく振り返ります。

論理演算子の罠:OR と AND の使い分け

テストコードを書いている時に、範囲外の値を弾く条件で大きくハマりました。

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }
        Guess { value }
    }
}

最初、この条件を if value < 1 && value > 100 と書いてしまいそうになりました。

なぜ || (OR) が正しいのか?

// ✅ 正しい
if value < 1 || value > 100 {
    panic!("範囲外!");
}

これは「valueが1未満 または 100より大きい」という意味です。

  • value = 0 → 0 < 1 が真 → panic!
  • value = 200 → 200 > 100 が真 → panic!
  • value = 50 → どちらも偽 → OK(panicしない)

もし && (AND) を使うと...

// ❌ 間違い
if value < 1 && value > 100 {
    panic!("範囲外!");
}

これは「valueが1未満 かつ 100より大きい」という意味になります。

数値が1未満かつ100より大きいことは数学的に不可能なので、この条件は常に false です。panicは絶対に起きません。

覚え方

日本語で考えると分かりやすいです:

  • 「1以上かつ100以下」→ value >= 1 && value <= 100 (範囲
  • 「1未満または100超過」→ value < 1 || value > 100 (範囲

有効な範囲を考える方が直感的なので、まず value >= 1 && value <= 100 を考えて、それを否定すると value < 1 || value > 100 になります(ド・モルガンの法則)。

まだぱっと見で理解できるほど親しみがないので、何度も書いて体に染み込ませる必要がありますね。

env::args() の仕組み:なぜ0番目から始まらないのか?

ミニgrepアプリを作っている時の疑問:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    let query = &args[1];      // なぜ0じゃなくて1?
    let filename = &args[2];   // なぜ1じゃなくて2?
}

答え:0番目にはプログラム名が入っている

$ cargo run searchstring example-filename.txt

実行すると args の中身はこうなります:

["target/debug/プログラム名", "searchstring", "example-filename.txt"]
  ↑ args[0]                    ↑ args[1]      ↑ args[2]
  • args[0] = プログラム自体のパス(実行ファイル名)
  • args[1] = 最初のコマンドライン引数(検索文字列)
  • args[2] = 2番目のコマンドライン引数(ファイル名)

これは誰の仕業?

env::args() の仕様です。

この関数がOSからコマンドライン引数を取得する際、0番目にプログラム名を含めるようになっています。Vec は関係なく、ただ env::args() から受け取ったものをそのまま格納しているだけです。

なぜこういう仕様なのか?

これはC言語の伝統から来ています:

// C言語の main 関数
int main(int argc, char *argv[]) {
    // argv[0] は常にプログラム名
    // argv[1] 以降が実際の引数
}

UNIX系のOSでは、プログラム起動時に「自分自身のパス」を argv[0] として渡すのが標準的な動作です。Rustの env::args() もこの慣習に従っています。

Node.jsとの比較

Node.jsにも process.argv という似たものがあります:

// node script.js search example.txt

console.log(process.argv);
// [
//   '/path/to/node',      // argv[0]: Node.js実行ファイルのパス
//   '/path/to/script.js', // argv[1]: スクリプトファイルのパス
//   'search',             // argv[2]: 最初の引数
//   'example.txt'         // argv[3]: 2番目の引数
// ]
言語0番目1番目実際の引数開始位置
Rustプログラム名1番目の引数args[1]
Node.jsnode実行ファイルスクリプト名argv[2]

Node.jsは「インタプリタ」と「スクリプト」の両方が入るので、実際の引数は [2] からですね。

テスト文字列のインデント問題

テストを書いていて、思わぬところでハマりました:

#[test]
fn one_result() {
    let query = "duct";
    let contents = "\
        Rust:
        safe, fast, productive.
        Pick three.";
    assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}

テストが失敗!

assertion `left == right` failed
  left: ["safe, fast, productive."]
 right: ["        safe, fast, productive."]
         ^^^^^^^^ 余分なスペース8個!

原因:リントツールが自動整形

このインデントは手動で入れたものではなく、rustfmtcargo fmt が自動的に追加したものでした。

フォーマッタがコードのインデントに合わせて、文字列リテラルの中身まで整形してしまったんです。

解決策1:インデントを削除

#[test]
fn one_result() {
    let query = "duct";
    let contents = "\
Rust:
safe, fast, productive.
Pick three.";
    assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}

バックスラッシュ \ を使いつつ、インデントを入れない方法。見た目は悪いですが正確です。

解決策2:バックスラッシュなし(おすすめ)

let contents = "Rust:
safe, fast, productive.
Pick three.";

この書き方ならフォーマッタが触りません。リントツールと戦わず、ツールに合わせた書き方をするのが楽ですね。

解決策3:indoc クレート

大きなプロジェクトでは indoc クレートを使う方法もあります:

use indoc::indoc;

let contents = indoc! {"
    Rust:
    safe, fast, productive.
    Pick three.
"};
// indocが自動で共通のインデントを削除してくれる

クロージャ:Rustのアロー関数

3つの関数定義が出てきて混乱しました:

fn add_one_v1(x: u32) -> u32 {
    x + 1
}

fn main() {
    let add_one_v2 = |x: u32| -> u32 { x + 1 };
    let add_one_v3 = |x| x + 1;
    
    println!("add one result v1: {}", add_one_v1(1));
    println!("add one result v2: {}", add_one_v2(1));
    println!("add one result v3: {}", add_one_v3(1));
}

これは何?

これはクロージャ(closure)で、3つとも全く同じことをする関数です。

add_one_v1 - 通常の関数

fn add_one_v1(x: u32) -> u32 {
    x + 1
}

普通の関数定義。

add_one_v2 - フルスペックのクロージャ

let add_one_v2 = |x: u32| -> u32 { x + 1 };
//               ^^^^^^^^^^^^^^^^^^^^^^^^
//               クロージャ(無名関数)
  • |x: u32| - 引数(パイプ | で囲む)
  • -> u32 - 戻り値の型
  • { x + 1 } - 処理内容

変数に関数を代入している形です。

add_one_v3 - 省略形のクロージャ

let add_one_v3 = |x| x + 1;

Rustが型を推論してくれるので、型注釈を省略できます。

JavaScriptで例えると

// 通常の関数
function addOneV1(x) {
    return x + 1;
}

// アロー関数(フル)
const addOneV2 = (x) => { return x + 1; };

// アロー関数(省略)
const addOneV3 = x => x + 1;

うっすらアロー関数かなとは思っていましたが、やはりそうでした。

JavaScriptとの違い:self は使える?

JavaScriptではアロー関数内で this が使えませんが、Rustのクロージャでは self は普通に使えます

struct Counter {
    count: u32,
}

impl Counter {
    fn make_incrementer(&mut self) -> impl FnMut() {
        || {
            self.count += 1;  // self が使える!
        }
    }
}

なぜ違うのか:

  • JavaScript: this は呼び出され方で決まる(動的バインディング)。アロー関数は this を持たず、外側のスコープを参照。
  • Rust: self は所有権システムで管理される。クロージャは環境をキャプチャする仕組みで、self も他の変数と同じようにキャプチャできる。

イテレータと所有権:元のデータは残る?

let v1: Vec<i32> = vec![1, 2, 3];
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
assert_eq!(v2, vec![2, 3, 4]);

疑問:v1 はまだ使えるの?

答え:はい、残ります

let v1: Vec<i32> = vec![1, 2, 3];
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();

println!("v1: {:?}", v1);  // v1: [1, 2, 3] ← まだ使える!
println!("v2: {:?}", v2);  // v2: [2, 3, 4]

なぜ v1 が残るのか?

v1.iter()
// ^^^^^^ 不変借用のイテレータを作る

iter()参照 &i32 を返すイテレータなので、所有権は移動しません。

イテレータの種類による違い

let v1 = vec![1, 2, 3];

// 1. iter() - 不変借用 (&T)
let v2: Vec<_> = v1.iter().map(|x| x + 1).collect();
println!("{:?}", v1);  // OK!v1 はまだ使える

// 2. iter_mut() - 可変借用 (&mut T)
let mut v1 = vec![1, 2, 3];
v1.iter_mut().for_each(|x| *x += 1);
println!("{:?}", v1);  // OK!v1 は [2, 3, 4] に変更された

// 3. into_iter() - 所有権移動 (T)
let v1 = vec![1, 2, 3];
let v2: Vec<_> = v1.into_iter().map(|x| x + 1).collect();
// println!("{:?}", v1);  // エラー!v1 はもう使えない
メソッド要素の型元のコレクション
iter()&T残る
iter_mut()&mut T残る(変更可能)✅
into_iter()T消える

重要な理解

基本的に mut をつけることで色々変更できるけど、そうでない場合は参照だったり、変更できなかったりします。参照の場合には clone() や新しいものを作成する(map + collect など)ことが必要です。

何度も何度も間違えて覚えないと馴染まないですね。

ミニgrepアプリの深掘り

ここからは、イテレータでリファクタされたミニgrepアプリを深掘りしていきます。

全体の流れ

1. コマンドライン引数を受け取る (main.rs)
2. 設定を作る (Config)
3. ファイルを読む (run関数)
4. 検索する (search関数)
5. 結果を表示

main.rs の解説

use std::env;
use std::process;
use playground::Config;

fn main() {
    let args = env::args();
    // env::args() は Iterator<Item = String> を返す
    // まだ何も実行されていない(遅延評価)
    
    let config = Config::new(args).unwrap_or_else(|err: &str| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });
    // Config::new に args イテレータを渡す
    // 成功したら Config、失敗したらエラーメッセージ表示して終了
    
    if let Err(err) = playground::run(config) {
        println!("Application error: {}", err);
        process::exit(1);
    };
}

Config::new の仕組み

impl Config {
    pub fn new(mut args: impl Iterator<Item = String>) -> Result<Self, &'static str> {
        args.next();
        
        let query = match args.next() {
            Some(query) => query,
            None => return Err("Didn't get a query string"),
        };
        
        let filename = match args.next() {
            Some(filename) => filename,
            None => return Err("Didn't get a file name"),
        };
        
        Ok(Self { query, filename })
    }
}

なぜ mut args が必要?

pub fn new(mut args: impl Iterator<Item = String>) -> ...
//         ^^^

イテレータは next() で進めると状態が変わるので、mut が必要です。

ステップバイステップで理解

実行: cargo run frog poem.txt

イテレータの中身:
["target/debug/playground", "frog", "poem.txt"]

1回目の next():

args.next();
// → "target/debug/playground" を取り出して捨てる
// イテレータは ["frog", "poem.txt"] になる

2回目の next():

let query = match args.next() {
    Some(query) => query,      // → "frog" を取得
    None => return Err("Didn't get a query string"),
};
// イテレータは ["poem.txt"] になる

3回目の next():

let filename = match args.next() {
    Some(filename) => filename,  // → "poem.txt" を取得
    None => return Err("Didn't get a file name"),
};
// イテレータは [] (空)になる

イテレータの next() は:

  • 要素があれば Some(値) を返す
  • 要素がなければ None を返す

Config と config の混乱

pub struct Config {  // ← 型名(大文字)
    pub query: String,
    pub filename: String,
}

pub fn run(config: Config) -> ... {
//         ^^^^^^  ^^^^^^
//         変数名   型名
//         小文字   大文字

大文字小文字だけの違いで、どっちがどっちか分からなくなってしまいました。

Rustの命名規則

// 型 = 大文字で始まる(PascalCase)
struct Config { ... }
struct User { ... }
enum Result { ... }

// 変数・関数 = 小文字で始まる(snake_case)
let config = ...
let user_name = ...
fn search(...) { ... }

前提知識(プログラミングの概念)よりも、Rust特有のルールがまだ定着していない感じですね。慣習的には型名と同じ小文字の変数名を使うことが多く、慣れると「あ、これは変数だな」ってすぐ分かるようになるはずです。

標準ライブラリ (std) の役割

ミニgrepアプリで使った std の各モジュールについて:

use std::io::prelude::*;
use std::{error::Error, fs::File};
use std::env;
use std::process;

1. std::io::prelude::*

use std::io::prelude::*;
//      ^^  ^^^^^^^ 
//      io  よく使うものをまとめたモジュール

prelude は「前奏曲」という意味で、よく使うトレイトをまとめて import するための便利モジュールです。

ここでは特に:

file.read_to_string(&mut contents)?;
//   ^^^^^^^^^^^^^^ このメソッドを使うために必要

Read トレイトが提供する read_to_string メソッドを使えるようにしています。

2. std::error::Error

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
//                                        ^^^^^^^^^^^^^^
//                                        どんなエラーでも入る箱

Box<dyn Error>どんな種類のエラーでも返せる型です。

  • Box = ヒープに確保された箱(サイズが分からないものを入れる)
  • dyn Error = Error トレイトを実装した何か(動的ディスパッチ)
let mut file = File::open(config.filename)?;
//                                        ^ エラーが起きたら自動で return
file.read_to_string(&mut contents)?;
//                                ^ これもエラーが起きたら return

? は「エラーなら早期リターン、成功なら続ける」という演算子です。

3. std::fs::File

use std::fs::File;
//      ^^  ^^^^
//      fs  ファイルシステム

ファイルを扱うための型です。

let mut file = File::open(config.filename)?;
//             ^^^^^^^^^^^ ファイルを開く(読み取り専用)
//                         Result<File, Error> を返す

4. std::env

let args = env::args();
//         ^^^^^^^^^^^ 環境変数やコマンドライン引数を扱う

env::args()コマンドライン引数のイテレータを返します。

5. std::process

process::exit(1);
//      ^^^^^^^^ プログラムを終了する
//               0 = 成功, 1以上 = エラー

プロセス(実行中のプログラム)を制御するモジュールです。

整理すると

// ファイル入出力
use std::io::prelude::*;  // Read トレイトなど
use std::fs::File;        // ファイル操作

// エラー処理
use std::error::Error;    // エラー型

// プログラム制御
use std::env;             // 環境・引数
use std::process;         // プロセス制御

すごい量で、これだけでめちゃめちゃお腹いっぱいになりました。

実は全部を今理解する必要はなく、「こういうのがあるんだな〜」くらいで十分です。使いながら覚えていけば、必要になったときに「あ、これ見たことある!」ってなります。

run 関数の解説

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
//                            ^^^^^^^^^^^^^^^^^^^^^^^^^^
//                            成功なら()、失敗ならエラー
    let mut file = File::open(config.filename)?;
    //  ^^^ mut が必要(ファイルから読み込むと状態が変わる)
    //                                        ^ エラーなら即return
    
    let mut contents = String::new();
    //  ^^^ これから文字を追加していくので mut
    
    file.read_to_string(&mut contents)?;
    //                  ^^^^^^^^^^^^^ 可変参照で渡す(中身を変更するため)
    //                                ^ これもエラーなら即return
    
    for line in search(&config.query, &contents) {
        //      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        //      search関数がVec<&str>を返す
        println!("{}", line);
    }
    
    Ok(())
    // () = "unit型" = 「何も返さない」という意味
    // でもResultなので Ok で包む必要がある
}

File::open の動き

File::openResult<File, Error> を返します:

  • 成功 → File が手に入る
  • 失敗 → ? が自動で return Err(...) してくれる

read_to_string の動き

read_to_string は:

  • ファイルの中身を contents に追加する
  • 成功したら読んだバイト数を返す(今回は使ってない)
  • 失敗したら Err を返す

search 関数の解説

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
//            ^^^                         ^^              ^^
//            ライフタイム(戻り値の参照が contents と同じ生存期間)

リファクタ前(コメントアウトされてる方)

let mut results = Vec::new();
for line in contents.lines() {
    if line.contains(query) {
        results.push(line);
    }
}
results
  1. 空のVecを作る
  2. 各行をループ
  3. queryが含まれてたら追加
  4. Vecを返す

リファクタ後(イテレータ版)

contents
    .lines()                              // 各行のイテレータ
    .filter(|line| line.contains(query))  // 条件に合う行だけ
    .collect()                            // Vecに集める

同じことを1行(チェーン)で表現しています。

詳しく見ると

contents.lines()
// "I'm nobody!\nAre you..." → ["I'm nobody!", "Are you...", ...]
// 各行を返すイテレータ
.filter(|line| line.contains(query))
//      ^^^^^^ クロージャ(各lineに対して実行)
//             true なら残す、false なら捨てる
.collect()
// イテレータの結果をVecに変換
// Vec<&str> になる

かなり複雑になってきていて、全然分かりませんでした。でも一つずつ分解していけば理解できました。

デリファレンス(*):参照を外す操作

最後にポインタについての質問です:

fn main() {
    let a = vec![1, 2, 3];
    let borrowed_a = &a;
    let b = vec![1, 2, 3];
    
    println!("equality: {}", *borrowed_a == b);
    println!("a: {:?}, b: {:?}", a, b);
    
    let mut moved_a = a;
    let muttably_borrowed_a = &mut moved_a;
    *muttably_borrowed_a = vec![1, 2, 3, 4];
    
    println!("moved_a: {:?}", moved_a);
}

疑問:* で型を揃えているってこと?

答え:参照を外す(デリファレンス)操作

* は**「参照を外す(デリファレンス)」** という操作です。型を揃えるというより、参照の中身を取り出すイメージです。

1. *borrowed_a == b の場合

let a = vec![1, 2, 3];
let borrowed_a = &a;
//               ^^ 参照(&Vec<i32>)
let b = vec![1, 2, 3];
//          ^^^^^^^^^^^ 実体(Vec<i32>)

println!("equality: {}", *borrowed_a == b);
//                       ^^^^^^^^^^^
//                       &Vec を Vec に戻す
  • borrowed_a の型: &Vec<i32> (参照)
  • b の型: Vec<i32> (実体)
  • *borrowed_a で参照を外して Vec<i32> にする
  • これで同じ型同士を比較できる

2. *muttably_borrowed_a = ... の場合

let mut moved_a = a;
let muttably_borrowed_a = &mut moved_a;
//                        ^^^^^^^^^^^^ 可変参照(&mut Vec<i32>)

*muttably_borrowed_a = vec![1, 2, 3, 4];
// ^^^^^^^^^^^^^^^^^^^
// 参照を外して、その中身を書き換える

* を付けることで:

  • &mut Vec<i32>Vec<i32> に戻す
  • その中身に新しいVecを代入

* のイメージ

参照 = 「データがある場所を指す矢印」

borrowed_a -----→ [1, 2, 3]
   &              実際のデータ

*borrowed_a = 矢印を辿って実際のデータを取り出す

型で見ると

let borrowed_a: &Vec<i32> = &a;
//              ^^^^^^^^^^ この型

*borrowed_a  // Vec<i32> になる(&が外れる)
//           ^^^^^^^^^^ この型

「型を揃える」というより、「参照(ポインタ)の先にある実体にアクセスする」 という操作です。

まとめ

今日は本当に盛りだくさんでした:

重要ポイント

  1. 論理演算子: 無効範囲の除外は || (OR)、有効範囲の確認は && (AND)
  2. env::args(): 0番目にプログラム名が入るUNIX由来の仕様
  3. イテレータ: iter() は参照なので元のデータは残る。into_iter() は所有権移動で元は消える
  4. クロージャ: JavaScriptのアロー関数に似ているが、Rustでは self も普通に使える
  5. デリファレンス (*): 参照を外して中身にアクセスする操作
  6. 命名規則: 型は PascalCase、変数・関数は snake_case
  7. ? 演算子: エラーハンドリングを簡潔に書ける

実感したこと

  • 前提知識よりもRust特有のルールがまだ定着していない
  • 何度も何度も間違えて覚えないと馴染まない
  • 一度に詰め込みすぎると頭がパンクする

でも、一つずつ分解して理解していけば、ちゃんと分かる。コンパイラが親切に教えてくれるので、エラーと付き合いながら自然に身につけていけそうです。

「あ、なんか自然に書けるようになってる」という瞬間を信じて、引き続きコツコツ進めます!