Rustとは、ガベージコレクション(使わなくなったメモリを自動で回収する仕組み)なしでメモリ安全性をコンパイル時に保証する、高速なプログラミング言語です。 その中心にあるのが「所有権」と「借用」というルールで、ここを理解すると Rust のコードがぐっと読みやすくなります。

この記事では、cargo new でプロジェクトを作るところから、基本文法・所有権・トレイト・イテレータまでをすべてコピペで動くコードと実際の実行結果で解説します。さらに、つまずきやすいコンパイルエラー(E0382・E0502・E0499・E0308・E0106)をわざと起こして、本物のエラー文と直し方を表にしました。最後に clap を使った小さなテキスト集計 CLI を作り、cargo test・cargo clippy・cargo fmt の結果まで載せています。検証環境は Rust 1.95.0(rustup)、edition 2024、Linux です。


Rust入門の早見表

文法の早見表

やりたいこと 書き方 ポイント
変数 let x = 5; 既定で変更不可
変更できる変数 let mut x = 5; mut を付ける
型を書く let price: u32 = 1200; 多くは推論されるので省略可
関数 fn add(a: i32, b: i32) -> i32 { a + b } 最後の式(; なし)が戻り値
if(式) let s = if ok { "A" } else { "B" }; 値を返せる
範囲ループ for i in 1..=3 { } 1..3 は 3 を含まない
値を返す loop let n = loop { break 8; }; break 値
構造体 struct Item { name: String, price: u32 } impl でメソッド追加
列挙型 enum Status { Draft, Ordered { id: u32 } } 値を持たせられる
分岐 match s { Status::Draft => …, _ => … } 全パターン網羅が必須
値がないかも Option<T>(Some(v) / None) null の代わり
失敗するかも Result<T, E>(Ok(v) / Err(e)) 例外の代わり
エラーを上に返す let n: u32 = s.parse()?; Err ならその場で return
借用(読む) fn f(v: &[String]) 所有権は移らない
借用(書く) fn f(s: &mut String) 同時に1つだけ
複製 let b = a.clone(); 中身ごとコピー
共通の振る舞い trait Summary { fn title(&self) -> String; } 他言語のインターフェースに近い
配列のような可変長 let v = vec![1, 2, 3]; Vec<T>
連想配列 HashMap<K, V> use std::collections::HashMap;
変換と集計 v.iter().filter(…).map(…).collect() イテレータ

cargo コマンドの早見表

コマンド 用途
cargo new 名前 新しいプロジェクト(実行ファイル)を作る
cargo new --lib 名前 ライブラリを作る
cargo run ビルドして実行(-- の後ろはプログラムへの引数)
cargo build --release 最適化ビルド(target/release/)
cargo check ビルドせず型チェックだけ(速い)
cargo test テスト実行
cargo add クレート名 依存を Cargo.toml に追加
cargo clippy よりよい書き方を提案するリンター
cargo fmt コード整形(--check で差分確認のみ)
cargo doc --open ドキュメント生成
rustc --explain E0382 エラー番号の詳しい解説

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Rust入門|所有権からCLI作成までコピペで動くコード付き」


Rustの最新版とedition 2024

edition(エディション)とは、Rust の文法の世代です。 3年ごとに区切りがあり(2015 / 2018 / 2021 / 2024)、edition が違うクレート(パッケージ)同士も組み合わせて使えます。edition 2024 は Rust 1.85 で安定化され、現在の cargo new は自動的に edition = "2024" を設定します。

項目 内容
リリース周期 6週間ごとに新しい安定版
公式ブログで確認できた最新安定版 1.98.1(2026年9月3日公開。2026年10月1日時点)
本記事の検証環境 rustc 1.95.0 / cargo 1.95.0(edition 2024)
最新の edition 2024

本記事のコードは edition 2024 の基本機能だけを使っているため、1.95 以降の安定版でそのまま動きます。rustup update で最新版に更新できます。

edition 2024 で使えるようになった書き方の例が「let チェーン」です。if let を && でつなげられます。

fn main() {
    let input = Some("12");
    if let Some(s) = input
        && let Ok(n) = s.parse::<u32>()
        && n > 10
    {
        println!("10より大きい: {n}");
    }
}
10より大きい: 12

同じコードを edition 2021 でコンパイルすると次のエラーになります。

error: let chains are only allowed in Rust 2024 or later

インストールとcargo newで最初のプログラム

# macOS / Linux(公式のインストーラー rustup)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Windows は https://rustup.rs から rustup-init.exe を実行

rustc --version
cargo --version
rustc 1.95.0 (59807616e 2026-04-14)
cargo 1.95.0 (f2d3ce0bd 2026-03-21)
cargo new hello
cd hello
cargo run
    Creating binary (application) `hello` package
   Compiling hello v0.1.0 (…/hello)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.06s
     Running `target/debug/hello`
Hello, world!

作られるファイルと Cargo.toml です。

.
|-- .gitignore
|-- Cargo.lock
|-- Cargo.toml
`-- src
    `-- main.rs
[package]
name = "hello"
version = "0.1.0"
edition = "2024"

[dependencies]

以降のサンプルは、hello/examples/ フォルダに basics.rs などの名前で保存し、cargo run --example basics で実行できます。


変数・型・制御構文・関数

// examples/basics.rs
fn main() {
    // 変数:既定で変更不可。mut で変更可
    let name = "ノート";
    let mut stock = 10;
    stock -= 3;
    println!("{name} の在庫: {stock}");

    // シャドーイング:同じ名前で新しい変数を作る
    let qty = "5";
    let qty: i32 = qty.parse().unwrap();
    println!("qty + 1 = {}", qty + 1);

    // 型
    let price: u32 = 1200;
    let rate: f64 = 0.1;
    let ok: bool = true;
    let mark: char = '★';
    let pair: (i32, &str) = (1, "one");
    let arr: [i32; 3] = [10, 20, 30];
    println!("{price} {rate} {ok} {mark} {:?} {:?} len={}", pair, arr, arr.len());

    // if は式(値を返す)
    let label = if stock > 5 { "十分" } else { "少ない" };
    println!("在庫は{label}");

    // ループ
    for i in 1..=3 {
        print!("{i} ");
    }
    println!();
    let mut n = 0;
    let found = loop {
        n += 1;
        if n * n > 50 {
            break n;
        }
    };
    println!("n*n > 50 となる最小の n = {found}");

    println!("税込: {}", with_tax(price));
}

// 関数:最後の式(セミコロンなし)が戻り値
fn with_tax(price: u32) -> u32 {
    price * 110 / 100
}
ノート の在庫: 7
qty + 1 = 6
1200 0.1 true ★ (1, "one") [10, 20, 30] len=3
在庫は十分
1 2 3 
n*n > 50 となる最小の n = 8
税込: 1320

let で作った変数は既定で変更できず、変更するときだけ mut を付けます。 「どの変数が変わるか」がコードから一目でわかるのが利点です。

型 意味 例
i32 / i64 符号付き整数 -5
u32 / u64 / usize 符号なし整数(usize は長さ・添字) 1200
f64 浮動小数点数 0.1
bool 真偽値 true
char 1文字(Unicode) '★'
&str 文字列スライス(借りた文字列) "ノート"
String 所有する文字列(伸び縮みできる) String::from("ノート")
(T, U) タプル (1, "one")
[T; N] 固定長配列 [10, 20, 30]
Vec<T> 可変長配列 vec![1, 2]

構造体・enumとmatch・OptionとResult

// examples/types.rs
#[derive(Debug, Clone, PartialEq)]
struct Item {
    name: String,
    price: u32,
    qty: u32,
}

impl Item {
    fn new(name: &str, price: u32, qty: u32) -> Self {
        Self { name: name.to_string(), price, qty }
    }
    fn subtotal(&self) -> u32 {
        self.price * self.qty
    }
}

#[derive(Debug)]
enum Status {
    Draft,
    Ordered { id: u32 },
    Cancelled(String),
}

fn describe(s: &Status) -> String {
    match s {
        Status::Draft => "下書き".to_string(),
        Status::Ordered { id } if *id > 100 => format!("注文済み(大きい番号 {id})"),
        Status::Ordered { id } => format!("注文済み #{id}"),
        Status::Cancelled(reason) => format!("取消: {reason}"),
    }
}

fn find_price(items: &[Item], name: &str) -> Option<u32> {
    items.iter().find(|i| i.name == name).map(|i| i.price)
}

#[derive(Debug)]
enum OrderError {
    Parse(std::num::ParseIntError),
    Zero,
}

impl std::fmt::Display for OrderError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            OrderError::Parse(e) => write!(f, "数値ではありません: {e}"),
            OrderError::Zero => write!(f, "0 は指定できません"),
        }
    }
}

// ? 演算子で ParseIntError を OrderError に自動変換するための実装
impl From<std::num::ParseIntError> for OrderError {
    fn from(e: std::num::ParseIntError) -> Self {
        OrderError::Parse(e)
    }
}

fn parse_qty(s: &str) -> Result<u32, OrderError> {
    let n: u32 = s.trim().parse()?; // 失敗したら Err を返して関数を抜ける
    if n == 0 {
        return Err(OrderError::Zero);
    }
    Ok(n)
}

fn main() {
    let items = vec![Item::new("ノート", 300, 2), Item::new("付箋", 200, 1)];
    println!("{:?}", items[0]);
    println!("小計: {}", items[0].subtotal());

    for s in [
        Status::Draft,
        Status::Ordered { id: 7 },
        Status::Ordered { id: 120 },
        Status::Cancelled("在庫切れ".into()),
    ] {
        println!("{}", describe(&s));
    }

    println!("{:?} {:?}", find_price(&items, "付箋"), find_price(&items, "消しゴム"));
    if let Some(p) = find_price(&items, "ノート") {
        println!("ノートは {p} 円");
    }
    let p = find_price(&items, "消しゴム").unwrap_or(0);
    println!("見つからなければ {p}");

    for s in ["3", "0", "abc"] {
        match parse_qty(s) {
            Ok(n) => println!("{s} -> Ok({n})"),
            Err(e) => println!("{s} -> Err: {e}"),
        }
    }
}
Item { name: "ノート", price: 300, qty: 2 }
小計: 600
下書き
注文済み #7
注文済み(大きい番号 120)
取消: 在庫切れ
Some(200) None
ノートは 300 円
見つからなければ 0
3 -> Ok(3)
0 -> Err: 0 は指定できません
abc -> Err: 数値ではありません: invalid digit found in string

match はすべてのパターンを書かないとコンパイルが通りません。 enum に値を追加したとき、処理を書き忘れた箇所をコンパイラが教えてくれます。

Option と Result の取り出し方の使い分けです。

書き方 動き 向いている場面
match すべての場合を書き分ける 処理が分岐する
if let Some(v) = x ある場合だけ処理 ない場合は何もしない
x.unwrap_or(既定値) ない・失敗なら既定値 既定値で続行できる
x? 失敗なら呼び出し元へ返す 関数の戻り値も Result / Option
x.expect("理由") 失敗なら理由を表示して停止 起動時の設定読み込みなど、続行できないとき
x.unwrap() 失敗なら停止 試し書き・テスト

所有権・借用・ライフタイム

所有権のルールは3つです。 ①値にはそれぞれ「所有者」となる変数が1つある、②所有者は同時に1つだけ、③所有者がスコープ({ } の範囲)を抜けると値は解放される。

// examples/ownership.rs
// 借用(読むだけ)
fn total_len(words: &[String]) -> usize {
    words.iter().map(|w| w.len()).sum()
}

// 可変の借用(書き換える)
fn add_suffix(s: &mut String) {
    s.push('様');
}

// 所有権を受け取る(ムーブ)
fn take(s: String) -> usize {
    s.len()
}

// ライフタイム注釈:戻り値の参照は a と b のどちらかから来る
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let a = String::from("在庫");
    let b = a; // ムーブ:a は使えなくなる
    println!("b = {b}");

    let c = b.clone(); // clone:中身ごと複製
    println!("b = {b}, c = {c}");

    let x = 5;
    let y = x; // i32 は Copy:x も使える
    println!("x = {x}, y = {y}");

    let words = vec![String::from("note"), String::from("pen")];
    println!("total_len = {}", total_len(&words));
    println!("words はまだ使える: {:?}", words);

    let mut name = String::from("山田");
    add_suffix(&mut name);
    println!("{name}");

    let n = take(name);
    println!("take の戻り値 = {n}");

    println!("longer = {}", longer("apple", "kiwi"));

    // スライス:所有せずに一部を参照
    let s = String::from("hello world");
    let first = &s[..5];
    println!("first = {first}");
}
b = 在庫
b = 在庫, c = 在庫
x = 5, y = 5
total_len = 7
words はまだ使える: ["note", "pen"]
山田様
take の戻り値 = 9
longer = apple
first = hello

String を別の変数に代入すると「ムーブ(所有権の移動)」になり、元の変数は使えなくなります。 一方、i32 のような小さな値は Copy トレイトを持つので、代入しても両方使えます。take の戻り値が 9 なのは、len() が文字数ではなくバイト数(「山田様」は UTF-8 で 3文字×3バイト)を返すためです。

flowchart LR
    A["let a = String::from(...)"] -->|"let b = a(ムーブ)"| B["b が所有者 / a は使えない"]
    B -->|"&b(借用)"| C["読むだけ・何個でも同時に可"]
    B -->|"&mut b(可変の借用)"| D["書き換え可・同時に1つだけ"]
    B -->|"b.clone()"| E["別の所有者 c が誕生"]

所有権の判断表(引数の型をどれにするか)

関数の中でやること 引数の型 呼び出し側 例
読むだけ &str / &[T] / &T f(&x) 長さを数える、検索する
中身を書き換える &mut T f(&mut x) 末尾に追加する
保存する・別スレッドへ渡す T(所有権を受け取る) f(x)(以後 x は使えない) 構造体のフィールドに入れる
受け取りたいが元も使い続けたい T f(x.clone()) コストが小さい、または一度きり
小さな数値・bool・char T(Copy) f(x)(x も使える) i32、f64

迷ったら「まず借用(&)で受け取り、コンパイラに求められたら所有権や clone を検討する」のが進めやすい順番です。引数は &String より &str、&Vec<T> より &[T] にすると、どちらの型からも呼べて便利です(後述の clippy でも指摘されます)。

借用のルール

ルール 内容
不変の借用 &T 同時にいくつでも作れる
可変の借用 &mut T 同時に1つだけ。その間は &T も作れない
借用の有効期間 最後に使われた位置まで(そこを過ぎれば次の借用ができる)
ライフタイム 'a 「参照がどの値から借りたものか」をコンパイラに伝える注釈

つまずきやすいコンパイルエラーと直し方

以下はわざとエラーを起こして rustc 1.95.0(edition 2024)で得たエラー文です。

E0382:ムーブ後に使った

fn main() {
    let a = String::from("在庫");
    let b = a;
    println!("{a} {b}");
}
error[E0382]: borrow of moved value: `a`
 --> e0382.rs:4:16
  |
2 |     let a = String::from("在庫");
  |         - move occurs because `a` has type `String`, which does not implement the `Copy` trait
3 |     let b = a;
  |             - value moved here
4 |     println!("{a} {b}");
  |                ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let b = a.clone();
  |              ++++++++

E0502:不変の借用中に変更した

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];
    v.push(4);
    println!("{first}");
}
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
 --> e0502.rs:4:5
  |
3 |     let first = &v[0];
  |                  - immutable borrow occurs here
4 |     v.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |                ----- immutable borrow later used here

push で Vec の領域が引っ越すと、first が指していた場所が無効になる可能性があるため、コンパイラが止めてくれています。

E0499:可変の借用を2つ同時に作った

fn main() {
    let mut s = String::from("a");
    let r1 = &mut s;
    let r2 = &mut s;
    r1.push('b');
    r2.push('c');
}
error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> e0499.rs:4:14
  |
3 |     let r1 = &mut s;
  |              ------ first mutable borrow occurs here
4 |     let r2 = &mut s;
  |              ^^^^^^ second mutable borrow occurs here
5 |     r1.push('b');
  |     -- first borrow later used here

E0308:型が合わない

fn half(n: i32) -> i32 {
    n / 2;
}
fn main() {
    let price: u32 = "300";
    println!("{} {}", half(4), price);
}
error[E0308]: mismatched types
 --> e0308.rs:1:20
  |
1 | fn half(n: i32) -> i32 {
  |    ----            ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     n / 2;
  |          - help: remove this semicolon to return this value

error[E0308]: mismatched types
 --> e0308.rs:5:22
  |
5 |     let price: u32 = "300";
  |                ---   ^^^^^ expected `u32`, found `&str`
  |                |
  |                expected due to this

E0106:ライフタイムが足りない

fn longer(a: &str, b: &str) -> &str {
    if a.len() >= b.len() { a } else { b }
}
error[E0106]: missing lifetime specifier
 --> e0106.rs:1:32
  |
1 | fn longer(a: &str, b: &str) -> &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 `a` or `b`
help: consider introducing a named lifetime parameter
  |
1 | fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
  |          ++++     ++          ++          ++

エラー別の直し方まとめ

エラー 意味 直し方 直した後
E0382 ムーブした値を使った 借用にする / clone() / 使う順番を変える let b = &a; または let b = a.clone();
E0502 読んでいる最中に書き換えた 先に値をコピーする / 借用の範囲を短くする let first = v[0];(i32 はコピー)
E0499 書き換え用の借用が2つ 1つずつ順番に使う r1 を使い終えてから r2 を作る
E0308 型の不一致 ; を外して戻り値にする / parse() などで変換 n / 2 / "300".parse::<u32>()?
E0106 戻り値の参照の出どころが不明 ライフタイム注釈 'a を付ける / 所有する型 String を返す fn longer<'a>(a: &'a str, b: &'a str) -> &'a str

Rust のエラー文には help: として直し方の候補が書かれています。 まず help: を読み、詳しく知りたいときは rustc --explain E0502 で公式の解説と例を表示しましょう。


トレイト(共通の振る舞い)

// examples/traits.rs
use std::fmt;

trait Summary {
    fn title(&self) -> String;
    // 既定の実装
    fn summary(&self) -> String {
        format!("[{}]", self.title())
    }
}

struct Article {
    headline: String,
}
struct Task {
    name: String,
    done: bool,
}

impl Summary for Article {
    fn title(&self) -> String {
        self.headline.clone()
    }
}
impl Summary for Task {
    fn title(&self) -> String {
        self.name.clone()
    }
    fn summary(&self) -> String {
        format!("{} {}", if self.done { "✔" } else { "□" }, self.name)
    }
}

impl fmt::Display for Task {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Task({})", self.name)
    }
}

// トレイトオブジェクト:違う型をまとめて扱う
fn print_all(items: &[Box<dyn Summary>]) {
    for i in items {
        println!("{}", i.summary());
    }
}

// ジェネリクス+トレイト境界:型ごとに最適化されたコードになる
fn show<T: Summary>(item: &T) {
    println!("show: {}", item.summary());
}

fn main() {
    let t = Task { name: "備品発注".into(), done: false };
    show(&t);
    println!("{t}");
    let list: Vec<Box<dyn Summary>> = vec![
        Box::new(Article { headline: "Rust入門".into() }),
        Box::new(Task { name: "レビュー".into(), done: true }),
    ];
    print_all(&list);
}
show: □ 備品発注
Task(備品発注)
[Rust入門]
✔ レビュー

Display トレイトを実装すると println!("{t}") で表示できるようになります。 #[derive(Debug, Clone, PartialEq)] は、よく使うトレイトの実装を自動で生成する書き方です。

書き方 決まるタイミング 向いている場面
fn f<T: Summary>(x: &T) / fn f(x: &impl Summary) コンパイル時 1回の呼び出しで型が1つに決まる
&dyn Summary / Box<dyn Summary> 実行時 違う型を1つの Vec に入れたい

コレクションとイテレータ

// examples/collections.rs
use std::collections::{HashMap, HashSet};

fn main() {
    let mut v = vec![5, 3, 8, 1];
    v.push(4);
    v.sort();
    println!("v = {:?}, len = {}, first = {:?}, get(10) = {:?}", v, v.len(), v.first(), v.get(10));

    let mut stock: HashMap<&str, u32> = HashMap::new();
    stock.insert("ノート", 10);
    *stock.entry("ペン").or_insert(0) += 5;
    *stock.entry("ペン").or_insert(0) += 2;
    let mut keys: Vec<_> = stock.iter().collect();
    keys.sort();
    println!("stock = {:?}", keys);

    let tags: HashSet<&str> = ["rust", "cli", "rust"].into_iter().collect();
    println!("tags.len() = {}", tags.len());

    // イテレータ
    let nums = [1, 2, 3, 4, 5, 6];
    let even_sq: Vec<i32> = nums.iter().filter(|&&n| n % 2 == 0).map(|n| n * n).collect();
    let sum: i32 = nums.iter().sum();
    let pos = nums.iter().position(|&n| n == 4);
    println!("even_sq = {:?}, sum = {sum}, position(4) = {:?}", even_sq, pos);

    for (i, w) in ["a", "b", "c"].iter().enumerate() {
        print!("{i}:{w} ");
    }
    println!();

    let words = "rust is fast and rust is safe";
    let mut freq: HashMap<&str, usize> = HashMap::new();
    for w in words.split_whitespace() {
        *freq.entry(w).or_default() += 1;
    }
    let mut top: Vec<_> = freq.into_iter().collect();
    top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
    println!("top3 = {:?}", &top[..3]);

    let s = String::from("こんにちは");
    println!("len(bytes) = {}, chars = {}", s.len(), s.chars().count());
}
v = [1, 3, 4, 5, 8], len = 5, first = Some(1), get(10) = None
stock = [("ノート", 10), ("ペン", 7)]
tags.len() = 2
even_sq = [4, 16, 36], sum = 21, position(4) = Some(3)
0:a 1:b 2:c 
top3 = [("is", 2), ("rust", 2), ("and", 1)]
len(bytes) = 15, chars = 5

v.get(10) は範囲外でも停止せず None を返します(v[10] は実行時に停止)。HashMap の entry().or_insert() は「なければ作って足す」を1行で書けるので、集計でよく使います。HashMap の順序は実行ごとに変わり得るため、表示前に並べ替えています。

イテレータのメソッド 用途 他言語でいうと
iter() / iter_mut() / into_iter() 借用 / 可変借用 / 所有権ごと取り出す –
map / filter 変換 / 絞り込み JS の map / filter
collect Vec や HashMap にまとめる list(...)
sum / count / max / min 集計 sum / len / max
find / position / any / all 検索 find / indexOf / some / every
enumerate / zip 番号付き / 2つを組にする Python の enumerate / zip
take / skip / rev 先頭 N 件 / 読み飛ばし / 逆順 slice / reverse

clapで小さなCLIを作る(テキスト集計ツール)

ファイルまたは標準入力のテキストから、行数・単語数・文字数と頻出単語の上位 N 件を表示する CLI を作ります。

cargo new wordstat
cd wordstat
cargo add clap --features derive
# Cargo.toml(追加された部分)
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }

src/lib.rs(集計ロジックとテスト)

use std::collections::HashMap;

/// テキストの集計結果
#[derive(Debug, PartialEq)]
pub struct Stats {
    pub lines: usize,
    pub words: usize,
    pub chars: usize,
}

/// 行数・単語数・文字数を数える
pub fn count(text: &str) -> Stats {
    Stats {
        lines: text.lines().count(),
        words: text.split_whitespace().count(),
        chars: text.chars().count(),
    }
}

/// 出現回数の多い単語を上位 n 件返す(同数は辞書順)
pub fn top_words(text: &str, n: usize, ignore_case: bool) -> Vec<(String, usize)> {
    let mut freq: HashMap<String, usize> = HashMap::new();
    for w in text.split_whitespace() {
        let w = w.trim_matches(|c: char| !c.is_alphanumeric());
        if w.is_empty() {
            continue;
        }
        let key = if ignore_case {
            w.to_lowercase()
        } else {
            w.to_string()
        };
        *freq.entry(key).or_default() += 1;
    }
    let mut v: Vec<(String, usize)> = freq.into_iter().collect();
    v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    v.truncate(n);
    v
}

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

    #[test]
    fn counts_lines_words_chars() {
        let s = count("a b\nこんにちは\n");
        assert_eq!(s, Stats { lines: 2, words: 3, chars: 10 });
    }

    #[test]
    fn top_words_ignores_case_and_punctuation() {
        let v = top_words("Rust, rust! Go go go.", 2, true);
        assert_eq!(v, vec![("go".to_string(), 3), ("rust".to_string(), 2)]);
    }

    #[test]
    fn empty_text() {
        assert_eq!(count(""), Stats { lines: 0, words: 0, chars: 0 });
        assert!(top_words("", 3, false).is_empty());
    }
}

src/main.rs(引数の解析と入出力)

use clap::Parser;
use std::io::Read;
use std::path::PathBuf;
use std::process::ExitCode;

/// テキストの行数・単語数・文字数と頻出単語を集計する
#[derive(Parser, Debug)]
#[command(version, about)]
struct Args {
    /// 入力ファイル(省略時は標準入力)
    file: Option<PathBuf>,

    /// 頻出単語を上位 N 件表示する
    #[arg(short, long, default_value_t = 3)]
    top: usize,

    /// 大文字小文字を区別しない
    #[arg(short = 'i', long)]
    ignore_case: bool,
}

fn read_input(file: &Option<PathBuf>) -> std::io::Result<String> {
    match file {
        Some(path) => std::fs::read_to_string(path),
        None => {
            let mut buf = String::new();
            std::io::stdin().read_to_string(&mut buf)?;
            Ok(buf)
        }
    }
}

fn main() -> ExitCode {
    let args = Args::parse();
    let text = match read_input(&args.file) {
        Ok(t) => t,
        Err(e) => {
            eprintln!("読み込みに失敗しました: {e}");
            return ExitCode::FAILURE;
        }
    };
    let s = wordstat::count(&text);
    println!("lines: {}  words: {}  chars: {}", s.lines, s.words, s.chars);
    for (i, (w, c)) in wordstat::top_words(&text, args.top, args.ignore_case)
        .iter()
        .enumerate()
    {
        println!("{:>2}. {w} ({c})", i + 1);
    }
    ExitCode::SUCCESS
}

ロジックを lib.rs、入出力を main.rs に分けると、ロジックだけを cargo test で確かめられます。 doc コメント(///)は clap のヘルプ文にそのまま使われます。

cargo run の結果

入力ファイル sample.txt の内容です。

Rust is fast.
Rust is safe, and rust is fun!
Cargo builds Rust code.
cargo run -q -- --help
テキストの行数・単語数・文字数と頻出単語を集計する

Usage: wordstat [OPTIONS] [FILE]

Arguments:
  [FILE]  入力ファイル(省略時は標準入力)

Options:
  -t, --top <TOP>    頻出単語を上位 N 件表示する [default: 3]
  -i, --ignore-case  大文字小文字を区別しない
  -h, --help         Print help
  -V, --version      Print version
cargo run -q -- sample.txt
cargo run -q -- sample.txt -i --top 2
echo "go go rust" | cargo run -q -- -t 1
lines: 3  words: 14  chars: 69
 1. Rust (3)
 2. is (3)
 3. Cargo (1)

lines: 3  words: 14  chars: 69
 1. rust (4)
 2. is (3)

lines: 1  words: 3  chars: 11
 1. go (2)

-i を付けると Rust と rust がまとめて数えられ、4 になります。エラー時の動きも確認しました。

$ cargo run -q -- nofile.txt; echo "exit=$?"
読み込みに失敗しました: No such file or directory (os error 2)
exit=1

$ cargo run -q -- --topp 2; echo "exit=$?"
error: unexpected argument '--topp' found

  tip: a similar argument exists: '--top'

Usage: wordstat --top <TOP> [FILE]

For more information, try '--help'.
exit=2

clap はオプション名の打ち間違いに「似た引数があります」と候補を出してくれます。 終了コードも 0(成功)・1(読み込み失敗)・2(引数エラー)と分かれるので、シェルスクリプトから使うときも判定しやすくなります。

cargo test の結果

running 3 tests
test tests::counts_lines_words_chars ... ok
test tests::empty_text ... ok
test tests::top_words_ignores_case_and_punctuation ... ok

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

cargo clippy と cargo fmt の結果

clippy の指摘を確かめるため、わざと回りくどく書いた関数を追加しました。

// 直す前
pub fn sum_len(v: &Vec<String>) -> usize {
    let mut total = 0;
    for i in 0..v.len() { total = total + v[i].len(); }
    return total;
}
warning: unneeded `return` statement
 --> src/bad.rs:4:5
warning: writing `&Vec` instead of `&[_]` involves a new object where a slice will do
 --> src/bad.rs:1:19
warning: the loop variable `i` is only used to index `v`
 --> src/bad.rs:3:14
warning: manual implementation of an assign operation
 --> src/bad.rs:3:27
warning: `wordstat` (lib) generated 4 warnings (run `cargo clippy --fix --lib -p wordstat -- ` to apply 2 suggestions)

cargo fmt --check は、整形が必要な箇所を差分で表示します(抜粋)。

-    for i in 0..v.len() { total = total + v[i].len(); }
+    for i in 0..v.len() {
+        total = total + v[i].len();
+    }
// 直した後(clippy・fmt ともに指摘なし)
pub fn sum_len(v: &[String]) -> usize {
    v.iter().map(|s| s.len()).sum()
}

clippy の指摘は「Rust らしい書き方」の教材になります。 &Vec<String> より &[String]、添字ループよりイテレータ、末尾の return は省略、が代表例です。cargo fmt で整形してから cargo clippy を通す習慣にすると、レビューで書き方の話をする時間が減ります。

コマンド 結果(本記事の CLI)
cargo test 3 passed
cargo clippy(直した後) 警告なし
cargo fmt --check(整形後) 差分なし(終了コード 0)

他言語との違い早見表

項目 Rust Python JavaScript / TypeScript Go Java
メモリ管理 所有権(コンパイル時) GC GC GC GC
null なし(Option) None null / undefined nil null
エラー処理 Result と ? 例外 例外 戻り値 error 例外
変数の既定 変更不可 変更可 const / let 変更可 変更可(final で不可)
継承 なし(トレイトで共通化) あり あり(class) なし(interface) あり
パッケージ管理 cargo(crates.io) pip / uv npm go mod Maven / Gradle
実行形態 ネイティブバイナリ インタプリタ JIT ネイティブバイナリ JVM
文字列の長さ len() はバイト数 文字数 UTF-16 単位 バイト数 UTF-16 単位

よくある質問

Q. Rustは難しいと聞きますが、どこから学べばよいですか? A. 本記事の順番(基本文法 → enum と match → Option / Result → 所有権 → トレイト)がおすすめです。所有権は最初から完璧に理解しなくても、コンパイラのエラー文と help: に従って直すうちに身につきます。公式の「The Rust Programming Language」(日本語版あり)と、ブラウザで動かせる Rust Playground も役立ちます。

Q. String と &str はどう使い分けますか? A. 関数の引数は &str(借りるだけ)、構造体のフィールドや戻り値で文字列を持ち続けたいときは String が基本です。&String は &str に自動で変換されるので、引数を &str にすれば両方から呼べます。

Q. unwrap() を使ってはいけないのですか? A. 試し書きやテストでは便利です。値がない・失敗したときにプログラムが停止するので、本番のコードでは ? で呼び出し元に返すか、unwrap_or で既定値にするか、expect("理由") で停止理由を残すと、原因を追いやすくなります。

Q. edition を 2021 から 2024 に上げるには? A. cargo fix --edition で自動修正できる箇所を直したあと、Cargo.toml の edition を "2024" に変更します。edition が違うクレート同士も一緒に使えるので、依存クレートの対応を待つ必要はありません。


まとめ

  • Rust は所有権のルールでメモリ安全性をコンパイル時に保証する言語で、cargo new ですぐ始められます(現在の既定は edition 2024)。
  • 変数は既定で変更不可、if や match は値を返す式です。
  • null の代わりに Option、例外の代わりに Result と ? を使います。
  • 引数はまず借用(&str・&[T])で受け取り、必要なときだけ所有権や clone を使うと進めやすくなります。
  • E0382・E0502・E0499・E0308・E0106 は help: に直し方が書かれているので、エラー文を読むのが近道です。
  • ロジックを lib.rs に分けると cargo test で確かめやすく、cargo fmt と cargo clippy で Rust らしい書き方に整えられます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Rust入門|所有権からCLI作成までコピペで動くコード付き」

株式会社RJCでは、Rustの所有権やツールの使い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?