[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::Reverse | use の読み方 |
今回は、この 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
| Java | Rust | |
|---|---|---|
| 何も付けない名前が、見える範囲 | 同じパッケージの中 | 同じモジュールの中(mod で作ったもの) |
| 外に出すとき | public | pub(構造体は、フィールドにも付ける) |
| 名前を短くして取り込む | import scan.Scan; | use crate::scan::...; |
| 取り込まないとき | cannot find symbol | E0425(型)、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、依存のlibmv0.2.16)、Cargo.tomlとCargo.lockの中身、cargo tree、初回ビルドの表示、cargo build --releaseでtarget/release/ができることhumansizeをCargo.tomlから消したときのE0432modの書き忘れ(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回・完結)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI
実践編(8回・予定。内容は、書きながら変わる可能性があります)
- Cargo・クレート・モジュール構成(今回・連載の第9回)
- ファイル操作・パス・文字コード
- 小さな CLI を作り込む(簡易アーカイバ)
- Tauri v2 の構造(フロントと Rust の役割分担)
- コマンドとイベントの橋渡し
- グローバルホットキー常駐(shotkey を分解)
- 設定の保存・トレイ・自動起動
- ビルド・署名・GitHub リリース