[Rust 実践] #09 Cargo とモジュール — ファイルを分けて、外部クレートを使う

[Rust 実践] #09 Cargo とモジュール — ファイルを分けて、外部クレートを使う

はじめに

入門編の最後に作った lsx は、1つの main.rs に、すべてを書きました。第6回から第8回で、説明なしに書いてきた use も、まだ回収していません。

出てきた回書き方今回の節
第6回use std::fs;use の読み方
第7回use std::fmt;use の読み方
第8回use std::{env, fs, io, process}、use std::cmp::Reverseuse の読み方

今回は、この lsx を複数のファイルに分けて、外部のクレートを1つ足して、テストを書きます。

Cargo が作るもの

cargo new lsx で作られるのは、次のものです。

lsx/
├── Cargo.toml
└── src/
    └── main.rs

Cargo.toml は、プロジェクトの設定ファイルです。

[package]
name = "lsx"
version = "0.1.0"
edition = "2024"

[dependencies]

cargo build や cargo run を一度実行すると、次の2つが増えます。

増えるもの中身
target/ビルドの成果物(target/debug/、cargo build --release なら target/release/ も)
Cargo.lock使ったクレートの、正確なバージョンの記録(次の節で中身を見ます)

クレートと use

クレートは、Rust のコンパイラが、一度に扱う単位です。lsx は、実行ファイルを作る1つのクレートです(コンパイルエラーに could not compile lsx (bin "lsx") と出るのが、これです)。標準ライブラリの std も、あとで足す humansize も、クレートです。

use は、長い名前を短く書くための宣言です。

書き方意味
use std::fs;std::fs::read_dir(...) を、fs::read_dir(...) と書けるようにする
use std::cmp::Reverse;std::cmp::Reverse を、Reverse と書けるようにする
use std::{env, fs, process};同じ std:: から、3つをまとめて取り込む
use crate::error::AppError;自分のクレートの中の error モジュールにある AppError。crate は、自分のクレートの一番上を指す

外部クレートを足す

ファイルサイズを、1516 ではなく 1.52 kB のように表示したいので、humansize というクレートを使います。足すには、cargo add を使います。

cargo add humansize
    Updating crates.io index
     Locking 2 packages to latest Rust 1.97.0 compatible versions
      Adding humansize v2.1.3
      Adding libm v0.2.16

Cargo.toml の [dependencies] に、1行増えました。

[dependencies]
humansize = "2.1.3"

cargo tree で、依存の関係が見られます。humansize は、さらに libm というクレートを使っています。

lsx v0.1.0 (…/lsx)
└── humansize v2.1.3
    └── libm v0.2.16

最初のビルドでは、依存クレートも一緒にコンパイルされます。

   Compiling libm v0.2.16
   Compiling humansize v2.1.3
   Compiling lsx v0.1.0 (…/lsx)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.55s

Cargo.lock には、バージョンと checksum(ハッシュ値)が、次のように記録されます(humansize の部分)。

[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
 "libm",
]

Cargo.toml には、足したときのバージョンの指定が、Cargo.lock には、実際に使った正確な版が、書かれます。

使うには、use で取り込みます。

use humansize::{DECIMAL, format_size};

let total = scan::total_size(&entries);
println!("合計: {} ({total} バイト)", format_size(total, DECIMAL));
合計: 1.52 kB (1516 バイト)

Cargo.toml から humansize の行を消して、use だけ残すと、止まります。

error[E0432]: unresolved import `humansize`
  --> src/main.rs:12:5
   |
12 | use humansize::{DECIMAL, format_size};
   |     ^^^^^^^^^ use of unresolved module or unlinked crate `humansize`
   |
   = help: if you wanted to use a crate named `humansize`, use `cargo add humansize` to add it to your `Cargo.toml`

use は、Cargo.toml にないクレートを、取り込めません。help が、cargo add を教えてくれています。

モジュールで、ファイルを分ける

lsx を、役割ごとに5つのファイルに分けます。

src/
├── main.rs     入口。run と main、画面への出力
├── cli.rs      引数の解釈(Options、parse_args)
├── entry.rs    1行分のデータ(Kind、Entry、Display)
├── error.rs    エラー型(AppError)
└── scan.rs     ディレクトリを読む(read_entries など)とテスト

分けるには、main.rs の先頭に、mod ファイル名; と書きます。

mod cli;
mod entry;
mod error;
mod scan;

mod cli; と書くと、Rust は src/cli.rs(または src/cli/mod.rs)を探して、cli というモジュールとして読み込みます。分けたあとの出力は(合計の表示を足す前の段階で)、分ける前の lsx と、名前順・--size の2通りとも、一字一句同じでした(md5sum で一致を確認)。cargo clippy も、指摘なしです。

mod を書き忘れると

mod scan; の行を消すと、止まります。

error[E0433]: cannot find module or crate `scan` in this scope
  --> src/main.rs:21:23
   |
21 |     let mut entries = scan::read_entries(opts.path)?;
   |                       ^^^^ use of unresolved module or unlinked crate `scan`
   |
help: to make use of source file src/scan.rs, use `mod scan` in this file to declare the module
   |
 1 + mod scan;
   |

ファイルがあっても、mod で宣言しないと、ないのと同じです。逆に、mod cli; と書いて src/cli.rs を消すと、こうなります。

error[E0583]: file not found for module `cli`
 --> src/main.rs:1:1
  |
1 | mod cli;
  | ^^^^^^^^
  |
  = help: to create the module `cli`, create file "src/cli.rs" or "src/cli/mod.rs"
書くもの役割
mod cli;「この名前のモジュールが、ここにある」と宣言する(ファイルを取り込む)
use crate::cli::Options;すでにあるモジュールの中の名前を、短く書けるようにする

pub を書き忘れると

Rust では、何も付けない名前は、そのモジュールの中でしか使えません。ほかのファイルから使うには、pub を付けます。scan.rs の pub fn read_entries から pub を消すと、こうなります。

error[E0603]: function `read_entries` is private
  --> src/main.rs:22:29
   |
22 |     let mut entries = scan::read_entries(opts.path)?;
   |                             ^^^^^^^^^^^^ private function
   |
note: the function `read_entries` is defined here
  --> src/scan.rs:6:1
   |
 6 | fn read_entries(path: &str) -> Result<Vec<Entry>, AppError> {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

struct は、pub struct Entry と書いても、フィールドは、それぞれに pub が必要です。entry.rs の pub size から pub を消すと、こうなります。

error[E0616]: field `size` of struct `entry::Entry` is private
  --> src/scan.rs:37:28
   |
37 |         let size = match e.size {
   |                            ^^^^ private field

このとき、entry.rs の中(Display の実装が self.size を読んでいます)では、エラーが出ませんでした。エラーが出たのは、scan.rs と main.rs からの参照だけです(合計3か所)。

最後に、use を書き忘れた場合です。scan.rs の use crate::error::AppError; を消すと、こうなります。

error[E0425]: cannot find type `AppError` in this scope
 --> src/scan.rs:5:55
  |
5 | pub fn read_entries(path: &str) -> Result<Vec<Entry>, AppError> {
  |                                                       ^^^^^^^^ not found in this scope

モジュールをまたぐと、名前は自動では見えません。ファイルごとに、必要な名前を use で取り込みます。

第8回のライフタイムとの関係

cli.rs の parse_args は、第8回では parse_args<'a>(args: &'a [String]) -> Result<Options<'a>, AppError> と書きました。今回は、省略して書いています。

pub fn parse_args(args: &[String]) -> Result<Options<'_>, AppError> {

scan.rs の largest_file も、fn largest_file(entries: &[Entry]) -> Option<&Entry> と、省略した形です。第8回の規則(参照の引数が1つなら、それが戻り値になる)どおりに、コンパイルが通りました。

テストを書く

Rust では、テストを、書いているファイルの中に書けます。scan.rs の末尾に、次を足しました。

#[cfg(test)]
mod tests {
    use super::*;

    fn file(name: &str, size: u64) -> Entry {
        Entry {
            name: name.to_string(),
            size: Some(size),
            kind: Kind::File,
        }
    }

    #[test]
    fn total_skips_dirs() {
        let list = vec![
            file("a", 10),
            Entry {
                name: "d".to_string(),
                size: None,
                kind: Kind::Dir,
            },
            file("b", 5),
        ];
        assert_eq!(total_size(&list), 15);
    }

    #[test]
    fn largest_of_empty_is_none() {
        let list: Vec<Entry> = Vec::new();
        assert!(largest_file(&list).is_none());
    }

    #[test]
    fn largest_picks_biggest() {
        let list = vec![file("a", 10), file("b", 30), file("c", 20)];
        assert_eq!(largest_file(&list).unwrap().name, "b");
    }
}
書き方意味
#[cfg(test)]cargo test のときだけ、このモジュールをコンパイルする
mod tests { ... }ファイルの中に書く、小さなモジュール
use super::*;親のモジュール(scan)で使える名前を、まとめて取り込む。Entry や Kind、total_size が、ここで使える
#[test]この関数を、テストとして実行する
assert_eq!(a, b)、assert!(x)等しくなければ、条件が真でなければ、パニックして、そのテストは失敗になる

cargo test の結果です。

running 3 tests
test scan::tests::largest_of_empty_is_none ... ok
test scan::tests::largest_picks_biggest ... ok
test scan::tests::total_skips_dirs ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

期待値を 15 から 99 に変えると、そのテストだけが失敗します。

test scan::tests::total_skips_dirs ... FAILED

---- scan::tests::total_skips_dirs stdout ----
thread 'scan::tests::total_skips_dirs' (1358) panicked at src/scan.rs:85:9:
assertion `left == right` failed
  left: 15
 right: 99

Java と並べる

Java で、同じように2つのパッケージに分けて、試しました。

// scan/Scan.java
package scan;

public class Scan {
    static long total(long[] sizes) { ... }               // 何も付けない
    public static long totalPublic(long[] sizes) { ... }  // public
}

別パッケージの app.Main から、何も付けない total を呼ぶと、止まります。

app/Main.java:9: error: total(long[]) is not public in Scan; cannot be accessed from outside package

public のほうは、import scan.Scan; を書けば通り、1516 と出ました。import を消すと、こうなります。

app/Main.java:7: error: cannot find symbol
        System.out.println(Scan.totalPublic(sizes));
                           ^
  symbol:   variable Scan
  location: class Main
JavaRust
何も付けない名前が、見える範囲同じパッケージの中同じモジュールの中(mod で作ったもの)
外に出すときpublicpub(構造体は、フィールドにも付ける)
名前を短くして取り込むimport scan.Scan;use crate::scan::...;
取り込まないときcannot find symbolE0425(型)、E0433(モジュール)
外部ライブラリの追加(今回は動かしていません)cargo add → Cargo.toml と Cargo.lock

サンプル: モジュールに分けた lsx

実行は、cargo run -- ../td --size のように、-- の後ろに引数を書きます。各ファイルの全文は、次のとおりです(entry.rs と error.rs は、第8回のコードに pub を付けただけです)。

src/main.rs

mod cli;
mod entry;
mod error;
mod scan;

use std::cmp::Reverse;
use std::env;
use std::fmt;
use std::process;

use error::AppError;
use humansize::{DECIMAL, format_size};

fn print_all<T: fmt::Display>(items: &[T]) {
    for item in items {
        println!("{item}");
    }
    println!("({} 件)", items.len());
}

fn run(args: &[String]) -> Result<(), AppError> {
    let opts = cli::parse_args(args)?;
    let mut entries = scan::read_entries(opts.path)?;
    if opts.by_size {
        entries.sort_by_key(|e| (Reverse(e.size), e.name.clone()));
    } else {
        entries.sort_by_key(|e| e.name.clone());
    }
    print_all(&entries);
    if let Some(e) = scan::largest_file(&entries) {
        println!("最大のファイル: {}", e.name);
    }
    let total = scan::total_size(&entries);
    println!("合計: {} ({total} バイト)", format_size(total, DECIMAL));
    Ok(())
}

fn main() {
    let args: Vec<String> = env::args().collect();
    if let Err(e) = run(&args) {
        eprintln!("エラー: {e}");
        process::exit(1);
    }
}

src/cli.rs

use crate::error::AppError;

pub struct Options<'a> {
    pub path: &'a str,
    pub by_size: bool,
}

pub fn parse_args(args: &[String]) -> Result<Options<'_>, AppError> {
    let mut path = ".";
    let mut by_size = false;
    for arg in &args[1..] {
        if arg == "--size" {
            by_size = true;
        } else if arg.starts_with("--") {
            return Err(AppError::Usage(format!("不明なオプション: {arg}")));
        } else {
            path = arg;
        }
    }
    Ok(Options { path, by_size })
}

src/entry.rs

use std::fmt;

pub enum Kind {
    File,
    Dir,
    Link,
    Other,
}

pub struct Entry {
    pub name: String,
    pub size: Option<u64>,
    pub kind: Kind,
}

impl fmt::Display for Entry {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let mark = match self.kind {
            Kind::File => "      ",
            Kind::Dir => "[DIR] ",
            Kind::Link => "[LNK] ",
            Kind::Other => "[???] ",
        };
        let size = match self.size {
            Some(n) => n.to_string(),
            None => String::from("-"),
        };
        write!(f, "{mark}{size:>8}  {}", self.name)
    }
}

src/error.rs

use std::fmt;
use std::io;

pub enum AppError {
    Usage(String),
    Io(io::Error),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> AppError {
        AppError::Io(e)
    }
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            AppError::Usage(msg) => write!(f, "{msg}\n使い方: lsx [パス] [--size]"),
            AppError::Io(e) => write!(f, "{e}"),
        }
    }
}

src/scan.rs(テストの部分は、前の節に載せました)

use std::fs;

use crate::entry::{Entry, Kind};
use crate::error::AppError;

pub fn read_entries(path: &str) -> Result<Vec<Entry>, AppError> {
    let mut entries = Vec::new();
    for item in fs::read_dir(path)? {
        let item = item?;
        let meta = item.metadata()?;
        let kind = if meta.is_dir() {
            Kind::Dir
        } else if meta.file_type().is_symlink() {
            Kind::Link
        } else if meta.is_file() {
            Kind::File
        } else {
            Kind::Other
        };
        let size = if meta.is_file() {
            Some(meta.len())
        } else {
            None
        };
        entries.push(Entry {
            name: item.file_name().to_string_lossy().into_owned(),
            size,
            kind,
        });
    }
    Ok(entries)
}

pub fn largest_file(entries: &[Entry]) -> Option<&Entry> {
    let mut best: Option<&Entry> = None;
    for e in entries {
        let size = match e.size {
            Some(n) => n,
            None => continue,
        };
        let better = match best {
            Some(b) => size > b.size.unwrap_or(0),
            None => true,
        };
        if better {
            best = Some(e);
        }
    }
    best
}

pub fn total_size(entries: &[Entry]) -> u64 {
    let mut total = 0;
    for e in entries {
        if let Some(n) = e.size {
            total += n;
        }
    }
    total
}

// (#[cfg(test)] mod tests { ... } は、前の節)

第8回と同じテスト用のディレクトリで、--size を付けて実行した結果です。

          1200  big.bin
           300  mid.txt
            10  メモ.txt
             6  note.txt
[DIR]        -  docs
[DIR]        -  empty
[LNK]        -  link.txt
(7 件)
最大のファイル: big.bin
合計: 1.52 kB (1516 バイト)

空のディレクトリは (0 件) のあとに 合計: 0 B (0 バイト)、不明なオプションと存在しないパスは、第8回と同じエラー表示(終了コード 1)でした。

歴史

年月日できごと
2014年3月17日Yehuda Katz 氏が、rust-dev メーリングリストで、Cargo の発表への返信を投稿
2014年6月23日Yehuda Katz 氏が、「Initial Alpha of Cargo」と題した告知を送信

2014年3月の投稿で、Katz 氏は、自分と Carl Lerche 氏を、Ruby のパッケージマネージャ Bundler の作者として紹介しています。同じ投稿には、同じソースと同じオプションなら別のマシンでも同じ成果物を作れること(決定論的ビルド)や、依存関係の取得元として、ローカルのファイルシステム、GitHub、中央のパッケージレポジトリを標準で扱うことが、方針として書かれています。

2014年6月の告知は、Cargo が試せる状態になったと伝えています。その時点では、依存関係はすべて GitHub から取得され、パッケージを公開できるレジストリは開発中、と書かれています。今後の計画には、shrinkwrap ファイルによる再現性の高いビルドが挙がっています。

動作確認

確認できたこと

  • 上のコードと出力は、すべて Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です。Java も、同じ環境(OpenJDK 21)で実行しました
  • cargo add humansize(v2.1.3、依存の libm v0.2.16)、Cargo.toml と Cargo.lock の中身、cargo tree、初回ビルドの表示、cargo build --release で target/release/ ができること
  • humansize を Cargo.toml から消したときの E0432
  • mod の書き忘れ(E0433)、ファイルがない(E0583)、pub の書き忘れ(関数は E0603、フィールドは E0616)、use の書き忘れ(E0425)。pub のない size を、entry.rs の中からは読めること
  • 分割前(第8回)と分割後の出力が、名前順・--size とも md5sum で一致すること。cargo clippy --all-targets と cargo fmt --check が指摘なしであること
  • cargo test(3件成功)と、期待値を変えたときの失敗の表示
  • Java の is not public in Scan; cannot be accessed from outside package と、import なしの cannot find symbol

歴史の出典

  • 2014年3月17日の Katz 氏の投稿と、2014年6月23日の告知は、rust-dev メーリングリストのアーカイブ(mail-archive.com)の記述です。取得ツールが返した要約を通して確認しました。6月の告知は、Brian Anderson 氏の返信(2014年6月24日)に引用された形で読んでいます。どちらも全文は読んでいません

確認できていないこと

  • crates.io の公開日と、公開までの経緯。公式ブログの記事(2014年11月20日付)は、リダイレクトのページしか取得できず、本文を読めていません
  • Cargo.lock が、いつ、どの形で導入されたか(6月の告知にあるのは、計画としての shrinkwrap ファイルまで)
  • ワークスペース、lib.rs と main.rs を分けるクレート構成、pub(crate)、use の別名(as)、mod をディレクトリで入れ子にする書き方。今回は動かしていません
  • Maven や Gradle と、Cargo の対応
  • Windows と macOS での動作。lsx のエラーメッセージは、Linux の文言です
  • humansize の DECIMAL の定義。この記事では、1516 バイトが 1.52 kB と表示された結果だけを確認しています

次回

次回は、ファイル操作・パス・文字コードを扱います。lsx で、to_string_lossy に任せていたファイル名の扱い(UTF-8 でない名前)や、パスを組み立てる型(Path と PathBuf)を、動かして確かめます。

連載の目次(予定)

入門編(8回・完結)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match
  6. Result / Option とエラー処理
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI

実践編(8回・予定。内容は、書きながら変わる可能性があります)

  1. Cargo・クレート・モジュール構成(今回・連載の第9回)
  2. ファイル操作・パス・文字コード
  3. 小さな CLI を作り込む(簡易アーカイバ)
  4. Tauri v2 の構造(フロントと Rust の役割分担)
  5. コマンドとイベントの橋渡し
  6. グローバルホットキー常駐(shotkey を分解)
  7. 設定の保存・トレイ・自動起動
  8. ビルド・署名・GitHub リリース