[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 str | x は、'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 では、参照が残っている限り、オブジェクトは、ガベージコレクタに片付けられないためです。
| Java | Rust | |
|---|---|---|
| 参照が、持ち主より長く残る | 起きない(参照が残る限り、オブジェクトが残る) | コンパイルエラー(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行は、別の小さなプログラムで、コンパイルが通ることを確認しました)
入門編のまとめ
| 回 | テーマ | 一言 |
|---|---|---|
| 1 | Rust とは / 環境構築 / Hello World | cargo で、作って動かす |
| 2 | 変数・型・関数 | 型が厳密で、桁あふれも、検査される |
| 3 | 所有権(ムーブ) | 値の持ち主は、1人。持ち主が、後始末をする |
| 4 | 借用と参照 | 貸すだけなら、&。書き換えるなら、&mut は1つだけ |
| 5 | 構造体・enum・match | 選択肢を型にして、全部の場合を書かせる |
| 6 | Result / 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回・完結)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI(今回)
実践編(8回・予定。内容は、書きながら変わる可能性があります)
- Cargo・クレート・モジュール構成
- ファイル操作・パス・文字コード
- 小さな CLI を作り込む(簡易アーカイバ)
- Tauri v2 の構造(フロントと Rust の役割分担)
- コマンドとイベントの橋渡し
- グローバルホットキー常駐(shotkey を分解)
- 設定の保存・トレイ・自動起動
- ビルド・署名・GitHub リリース