[Rust 入門] #08 ライフタイムと総まとめ — 参照の寿命を確かめて、ファイル一覧ツールを作る

[Rust 入門] #08 ライフタイムと総まとめ — 参照の寿命を確かめて、ファイル一覧ツールを作る

はじめに

入門編の最後に、後回しにしていた3つを回収します。

出てきた回書き方今回の節
第4回E0106 missing lifetime specifier関数のライフタイム、構造体が参照を持つとき
第4回E0597 does not live long enough関数のライフタイム、構造体が参照を持つとき
第2回&'static str の 'static'static

回収したあとは、第1回から第8回までの内容を使って、ファイル一覧のコマンドラインツールを作ります。

ライフタイム

第4回で、「参照は、持ち主より長く生きられない」と見ました。この「生きている期間」が、ライフタイムです。通常は、Rust が自動で調べていますが、調べきれないときに、書く必要が出ます。

2つの参照から、1つを返す

長いほうの文字列を返す関数を、書いてみます。

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

第4回の fn dangle() -> &String と、同じエラーです。今回の help は、「返す参照は、x から借りたものか、y から借りたものか、署名からは、わからない」と言っています。

help が示した形に直すと、通ります。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() >= y.len() { x } else { y }
}

fn main() {
    let a = String::from("apple");
    let b = String::from("fig");
    let r = longest(&a, &b);
    println!("{r}");
}
apple
部分意味
<'a>'a という名前の、ライフタイムを使う、という宣言。第7回の <T> と同じ位置
x: &'a strx は、'a のあいだ有効な参照
-> &'a str返す参照も、'a のあいだ有効

'a は、「x と y と、戻り値の参照は、同じ期間を共有する」という約束です。実際には、x と y のうち短いほうに、合わせられます。

短いほうに、合わせられる

内側の { } の中で作った b を、longest に渡して、結果を外で使ってみます。

fn main() {
    let a = String::from("apple");
    let r;
    {
        let b = String::from("fig");
        r = longest(&a, &b);
    }
    println!("{r}");
}
error[E0597]: `b` does not live long enough
  --> l3.rs:10:25
   |
 9 |         let b = String::from("fig");
   |             - binding `b` declared here
10 |         r = longest(&a, &b);
   |                         ^^ borrowed value does not live long enough
11 |     }
   |     - `b` dropped here while still borrowed
12 |     println!("{r}");
   |                - borrow later used here

第4回の E0597 と、同じ形です。実行すれば、longest は a("apple")を返すので、b が捨てられても、問題は起きないはずです。それでも、コンパイラは、通しません。関数の署名の 'a だけで、判断しているためです。

同じ呼び出しでも、r を内側でしか使わなければ、通ります。

{
    let b = String::from("fig");
    r = longest(&a, &b);
    println!("内側: {r}");
}
println!("{}", a);
内側: apple
apple

'a は、参照の寿命を延ばすものではありません。「この参照たちは、この期間は有効でなければならない」と、関係を書くものです。

省略できる場合

第4回から、ライフタイムを書かずに、参照を返す関数を、何度も書いてきました。

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

struct Doc {
    text: String,
}

impl Doc {
    fn head(&self) -> &str {
        first_word(&self.text)
    }
}

fn main() {
    println!("{}", first_word("hello world"));
    let d = Doc { text: String::from("rust lifetimes") };
    println!("{}", d.head());
}
hello
rust

first_word は、引数の参照が1つだけなので、書かなくても通りました。longest は、引数の参照が2つあり、どちらから返すか決められないので、書く必要がありました。

Rust のライフタイム省略の規則は、RFC 141 に、次のように書かれています。

規則
引数の、省略されたライフタイムは、それぞれ、別のライフタイムになる
引数のライフタイムがちょうど1つなら、それが、戻り値の省略されたライフタイムすべてになる
引数に複数あっても、&self か &mut self があれば、self のライフタイムが、戻り値になる
それ以外で、戻り値のライフタイムを省略すると、エラー

first_word(引数が1つ)、Doc::head(&self)、longest(引数が2つで self なし)の結果と、一致しています。

構造体が、参照を持つとき

構造体のフィールドに、参照を入れるときも、ライフタイムを書きます。

struct Excerpt {
    part: &str,
}
error[E0106]: missing lifetime specifier
 --> l5.rs:2:11
  |
2 |     part: &str,
  |           ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct Excerpt<'a> {
2 ~     part: &'a str,
  |

help のとおりに直すと、通ります。

struct Excerpt<'a> {
    part: &'a str,
}

fn main() {
    let text = String::from("call me ishmael. some years ago");
    let e = Excerpt { part: &text[..15] };
    println!("{}", e.part);
}
call me ishmael

Excerpt<'a> は、「part が借りている元のデータより、この Excerpt が長く生きてはいけない」という意味です。元のデータが先に捨てられると、止まります。

let e;
{
    let text = String::from("call me ishmael. some years ago");
    e = Excerpt { part: &text[..15] };
}
println!("{}", e.part);
error[E0597]: `text` does not live long enough
  --> l5c.rs:9:30
   |
 8 |         let text = String::from("call me ishmael. some years ago");
   |             ---- binding `text` declared here
 9 |         e = Excerpt { part: &text[..15] };
   |                              ^^^^ borrowed value does not live long enough
10 |     }
   |     - `text` dropped here while still borrowed
11 |     println!("{}", e.part);
   |                    ------ borrow later used here

‘static

第2回の &'static str の 'static は、プログラムの実行が終わるまで有効という、特別なライフタイムです。文字列リテラルが、これです。

fn greeting() -> &'static str {
    "こんにちは"
}
こんにちは

リテラルは、関数が終わっても有効なので、返せました。関数の中で作った String を、'static として返そうとすると、止まります。

fn greeting() -> &'static str {
    let s = String::from("こんにちは");
    &s
}
error[E0515]: cannot return reference to local variable `s`
 --> l6b.rs:3:5
  |
3 |     &s
  |     ^^ returns a reference to data owned by the current function

'static を求める引数に、String への参照を渡しても、止まります。

fn keep(s: &'static str) {
    println!("{s}");
}

fn main() {
    let owned = String::from("abc");
    keep("literal");
    keep(&owned);
}
error[E0597]: `owned` does not live long enough
 --> l6c.rs:8:10
  |
7 |     keep("literal");
8 |     keep(&owned);
  |     -----^^^^^^-
  |     |    |
  |     |    borrowed value does not live long enough
  |     argument requires that `owned` is borrowed for `'static`
9 | }
  | - `owned` dropped here while still borrowed

keep("literal") は通り、keep(&owned) は止まりました。

Java と並べる

Java では、同じ形のコードが、そのまま通ります。

static String longest(String x, String y) {
    return x.length() >= y.length() ? x : y;
}

static String make() {
    String s = new String("local");
    return s;
}

String a = "apple";
String r;
{
    String b = new String("banana-split");
    r = longest(a, b);
}
System.out.println(r);
System.out.println(make());
banana-split
local

内側の { } を出た後も、r は b のオブジェクトを指せて、make() の中で作ったオブジェクトも、返せました。Java では、参照が残っている限り、オブジェクトは、ガベージコレクタに片付けられないためです。

JavaRust
参照が、持ち主より長く残る起きない(参照が残る限り、オブジェクトが残る)コンパイルエラー(E0597、E0515)
参照の寿命を、型に書くか書かない必要なとき、'a と書く(省略できる場合が多い)
持ち主を捨てる時期実行時に、ガベージコレクタが決めるコンパイル時に、スコープの終わりで決まる(第3回)

サンプル: ファイル一覧ツール(lsx)

入門編の総まとめとして、ディレクトリの中身を一覧するコマンドラインツールを作ります。

コマンド動き
lsxカレントディレクトリを、名前順に一覧する
lsx パス指定したディレクトリを、名前順に一覧する
lsx --sizeサイズの大きい順に並べる(パス と組み合わせられる)
cargo new lsx
cd lsx

src/main.rs を、次のようにします。

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

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

struct Entry {
    name: String,
    size: Option<u64>,
    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)
    }
}

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}"),
        }
    }
}

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

fn parse_args<'a>(args: &'a [String]) -> Result<Options<'a>, 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 })
}

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)
}

fn largest_file<'a>(entries: &'a [Entry]) -> Option<&'a Entry> {
    let mut best: Option<&'a 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
}

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 = parse_args(args)?;
    let mut entries = 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) = largest_file(&entries) {
        println!("最大のファイル: {}", e.name);
    }
    Ok(())
}

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

実行は、cargo run -- 引数 と書きます(-- より後ろが、lsx への引数です)。次のファイルを用意して、動かしました。

big.bin    (1200 バイト)
mid.txt    (300 バイト)
note.txt   (6 バイト)
メモ.txt   (10 バイト)
docs/      (空のディレクトリ)
empty/     (空のディレクトリ)
link.txt   (note.txt へのシンボリックリンク)

名前順(lsx パス)の結果です。

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

--size を付けると、次のとおりです。

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

3回続けて実行して、出力が同じになることも確認しました。エラーのときは、次のとおりです(どれも終了コードは 1)。

エラー: No such file or directory (os error 2)
エラー: Not a directory (os error 20)
エラー: 不明なオプション: --foo
使い方: lsx [パス] [--size]

(順に、存在しないパス、ファイルのパス、--foo を渡した場合)空のディレクトリは、(0 件) と出て、正常に終わりました。

初めて出てきた書き方

書き方意味
env::args().collect()コマンドライン引数を、Vec<String> に集める。最初の要素は、プログラム自身の名前
fs::read_dir(path)?ディレクトリの中身を、1つずつ取り出す。取り出す各要素も Result なので、item? と書く
item.metadata()?サイズや種類を調べる
meta.is_dir()、meta.is_file()、meta.file_type().is_symlink()ディレクトリか、ファイルか、シンボリックリンクか
item.file_name().to_string_lossy().into_owned()ファイル名を、String に直す
continueループの残りを飛ばして、次の周回に進む
sort_by_key(|e| ...)並べ替えの鍵を返す関数で、並べ替える。|e| ... は、名前のない関数(クロージャ)。この連載では、詳しくは扱わない
Reverse(...)並べ替えの順序を、逆にする
(Reverse(e.size), e.name.clone())鍵が複数のとき、左から順に比べる。サイズが同じとき(ディレクトリなど)は、名前で並ぶ
{size:>8}8 文字の幅に、右寄せで表示する
eprintln!標準エラー出力に、書く
process::exit(1)終了コード 1 で、プログラムを終える

最初は sort_by(|a, b| b.size.cmp(&a.size)) と書きましたが、cargo clippy が sort_by_key と Reverse を勧めたので(unnecessary_sort_by)、そのとおりに直しました。名前順で並べると、read_dir の返す順番に左右されなくなります。

第1回から第8回まで、どこを使ったか

回使ったもの
第1回cargo new、cargo run、println!
第2回let と mut、if / else if、for、関数、&str
第3回Vec<Entry> の所有権を、read_entries から呼び出し元へ返す。String を Entry に渡す(ムーブ)
第4回&[Entry]、&str、for e in entries(借りて回す)
第5回struct Entry、enum Kind・enum AppError、impl、match
第6回Result、Option、?、if let、unwrap_or
第7回impl fmt::Display、impl From<io::Error>、print_all<T: fmt::Display>
第8回Options<'a>、parse_args<'a>、largest_file<'a>

Options<'a> は、args から借りた path を、持っています。parse_args の戻り値の Options が、args より長く生きないことを、'a で書いています。

省略しても、通ります。

fn parse_args(args: &[String]) -> Options<'_>
fn largest(entries: &[Entry]) -> Option<&Entry>

('_ は、省略されたライフタイムを、書かずに示す書き方です。この2行は、別の小さなプログラムで、コンパイルが通ることを確認しました)

入門編のまとめ

回テーマ一言
1Rust とは / 環境構築 / Hello Worldcargo で、作って動かす
2変数・型・関数型が厳密で、桁あふれも、検査される
3所有権(ムーブ)値の持ち主は、1人。持ち主が、後始末をする
4借用と参照貸すだけなら、&。書き換えるなら、&mut は1つだけ
5構造体・enum・match選択肢を型にして、全部の場合を書かせる
6Result / Option失敗と「ない」を、型で扱う
7トレイトとジェネリクス型に約束を付け、どの型にも使える形で書く
8ライフタイム + CLI参照の寿命を、型で関係づける

歴史

年できごと
2014年6月24日RFC 141「ライフタイムの省略」の開始日

RFC 141 は、標準ライブラリ(libstd)で、ライフタイムの注釈が必要だった 169 か所のうち、147 か所(87%)が、新しい規則で省略できる、と見積もっています。

Rust のリファレンスには、Rust が影響を受けた言語と、取り入れたものの一覧があります。この連載に関わるものを、抜き出します。

影響元取り入れたものこの連載の回
C++参照、RAII、スマートポインタ、ムーブ第3回、第4回
SML、OCaml代数的データ型、パターンマッチ第5回
Haskell(GHC)型クラス(typeclasses)第7回
ML Kit、Cycloneリージョンに基づくメモリ管理第8回(ライフタイム)

動作確認

確認できたこと

  • 上のコードと出力は、すべて、Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です
  • Java のコードは、同じ環境の Java(OpenJDK 21)で、実際に実行した結果です
  • 6つのエラー(E0106 を2種類の形、E0597 を3種類の形、E0515)のメッセージ
  • longest が、'a を付けると通り、内側のスコープで作った b を渡して、結果を外で使うと E0597 になること。結果を内側でしか使わなければ、通ること
  • 引数の参照が1つの関数、&self のメソッドで、ライフタイムを省略して通ること。引数の参照が2つの関数では、E0106 になること
  • 構造体が参照を持つとき、<'a> が必要なこと。元のデータが先に捨てられると E0597 になること
  • 文字列リテラルを &'static str として返せること。String の参照は、返せず(E0515)、'static の引数にも渡せないこと(E0597)
  • parse_args と largest_file の 'a を省略した形(Options<'_>、Option<&Entry>)が、別の小さなプログラムで通ること
  • Java で、内側のスコープで作ったオブジェクトを、外で参照できること。関数の中で作ったオブジェクトを返せること
  • lsx の動作(名前順、--size、空のディレクトリ、存在しないパス、ファイルのパス、不明なオプション、引数なし、cargo run -- 経由の実行)。--size の出力が、3回の実行で同じだったこと。cargo clippy と cargo fmt --check が、指摘なしで通ること

歴史の出典

  • RFC 141 の開始日(2014-06-24)、169 か所のうち 147 か所(87%)、省略の規則の内容は、Rust の RFC ブック(rust-lang.github.io/rfcs)の記述です。取得ツールが返した要約を通して確認しました。RFC の全文は、読んでいません
  • 影響を受けた言語の一覧は、Rust のリファレンス(doc.rust-lang.org/reference/influences.html)の記述です。取得ツールの要約を通して確認しました。このページは、リージョンに基づくメモリ管理を、ML Kit と Cyclone から来たものとして挙げていますが、Rust がそれをどう使っているかは、書いていません

確認できていないこと

  • RFC 141 が、いつ採用され、いつ Rust に入ったか。この記事では、開始日だけを書いています
  • Rust のライフタイムが、ML Kit や Cyclone のリージョンから、どの程度の影響を受けたか。リファレンスの一覧に挙がっていること以上は、確かめていません
  • 'a の「実際には、短いほうに合わせられる」という説明の、型システム上の正確な定義。この記事のコードでの動きだけを、確認しました
  • ファイル名が UTF-8 でない場合の lsx の動き(to_string_lossy の挙動)
  • Windows と macOS での、lsx の動き。エラーメッセージ(No such file or directory (os error 2) など)は、Linux の文言です。シンボリックリンクの表示も、Linux でだけ確認しました

次回

入門編は、今回で終わりです。次回からは、実践編に入ります。入門編で書いた lsx は、1つのファイルに、すべてを書きました。実践編の第1回では、Cargo・クレート・モジュール構成を扱い、コードを複数のファイルに分けて、外部のクレートを使う方法を見ていきます。

連載の目次(予定)

入門編(8回・完結)

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

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

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