コンテンツにスキップ

Rustのファイル入出力

ファイルの読み書きは、これまで学んできた Result や ? 演算子が、机上の話ではなく実際に役立ち始める最初の場面のひとつです。ファイル操作はほとんどすべて失敗する可能性があります(ファイルが存在しないかもしれないし、権限がないかもしれません)。そのため標準ライブラリは、その失敗にきちんと向き合うことを求めてきます。

ファイルの中身を String に読み込む、いちばんかんたんな方法です。

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("ファイルを読み込めませんでした: {e}"),
}
}

fs::read_to_string は Result<String, std::io::Error> を返します。うまくいけば中身のテキストを持った Ok、失敗すれば理由(ファイルがない、権限がない、など)を持った Err です。

? でエラー処理をシンプルにする

Section titled “? でエラー処理をシンプルにする”

ファイル操作のたびに match を書いていると、すぐに同じことの繰り返しになります。Result を返す関数の中にいるなら、(Result のページで学んだ)? 演算子を使うと、ぐっとすっきりします。

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // 失敗したらここで早期リターンする
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("エラー: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "こんにちは、ファイル!");
match result {
Ok(()) => println!("書き込みに成功しました"),
Err(e) => println!("書き込みに失敗しました: {e}"),
}
}

fs::write は、ファイルがなければ新しく作り、すでにあれば中身を丸ごと上書きします。ファイルを置き換えるのではなく書き足したいときは、この点に気をつけてください。

既存のファイルを上書きせずに書き足したいときは、OpenOptions を使います。

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "新しいログの行")?;
Ok(())
}

ここでは main 自体が Result を返していることに注目してください。これは覚えておく価値のある書き方です。main が Result を返すようにすると、すべてを match で包まなくても、main の中で直接 ? を使えます。

大きなファイルの場合、一度に全部をメモリに読み込むのが最適とは限りません。代わりに1行ずつ読み込む方法はこうです。

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

どのエラーが起きたのかを調べる

Section titled “どのエラーが起きたのかを調べる”

ここまでは、どのエラーも同じように扱ってきました。表示するだけです。でも実際には、何がうまくいかなかったのかによって対応を変えたいことがよくあります。ファイルがないのは、たいてい問題ありません(初めての実行なら、空の状態から始めればいいだけです)。一方、「権限がない」は、きちんと報告すべき本当の問題です。

io::Error は .kind() でエラーの種類を教えてくれます。返ってくるのは ErrorKind(英語)という列挙型なので、match で分岐できます。

use std::fs;
use std::io::ErrorKind;
fn main() {
// システムの一時フォルダにある、存在しないファイル
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("{}行を読み込みました", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("メモはまだありません。新しく始めます"),
ErrorKind::PermissionDenied => println!("メモのファイルを読む権限がありません"),
_ => println!("別の問題が起きました: {e}"),
},
}
}

実行結果:

メモはまだありません。新しく始めます
▶ 実際に試してみよう

std::env::temp_dir() はシステムの一時フォルダ(Linuxなら /tmp など)のパスを返し、.join(...) でそこにファイル名を付け足しています。ファイル操作の関数は、&str と同じように、こういうパスもそのまま受け取れます。

最後には必ず _ のアームを置いておきましょう。ErrorKind にはバリアントがたくさんあり、時間とともに新しいものが追加されていきます。_ のアームが、書かなかったものをすべて受け止めてくれます。

どちらの問題も、まず一時フォルダに自分でファイルを書き込むので、Playground も含めてどこでも動きます。

fs::write で、3つのToDo項目を1行に1つずつファイルに書き込んでください。それから fs::read_to_string でファイルを読み戻し、各行の前に番号を付けて表示し、最後に合計を表示します。

ヒント:項目の間に \n を入れると、別々の行になります。text.lines() で1行ずつ取り出せます。? を使えるように、main は std::io::Result<()> を返すようにしましょう。

解答を見る
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "所有権を学ぶ\nモジュールを書く\nファイルを読む\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("全部で{count}項目");
Ok(())
}

実行結果:

1. 所有権を学ぶ
2. モジュールを書く
3. ファイルを読む
全部で3項目
▶ 実際に試してみよう

fs::write はファイル全体を置き換えるので、何回実行しても同じ3行になります。書き込みや読み込みに失敗すると、それぞれの ? が main からエラーを返します。

見出しの行を書いてログファイルを新しく始め、そこに OpenOptions と writeln! でさらに2行を追記してください。最後に、ファイル全体を読み戻して表示します。

ヒント:見出しには fs::write を使い(毎回まっさらな状態から始めるため)、追加の行には OpenOptions::new().append(true).open(&path)? を使います。ファイルはすでに改行で終わっているので、println! ではなく print! を使うと、最後に余分な空行が出ません。

解答を見る
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== ログ ===\n")?; // まっさらな状態から始める
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "開始")?;
writeln!(file, "終了")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

実行結果:

=== ログ ===
開始
終了
▶ 実際に試してみよう

use std::fs::{self, OpenOptions}; は、fs モジュールそのものと OpenOptions の両方を取り込みます。最初の fs::write がないと、実行するたびに古いファイルへ書き足され続け、ログはどこまでも大きくなります。本物のログならまさにそれが望みですが、決まった出力になるはずの練習問題では困ります。

  • fs::read_to_string(path) はファイル全体を String に読み込み、Result を返す。
  • fs::write(path, contents) はファイルに書き込む。すでにファイルがあれば上書きする。
  • OpenOptions::new().append(true) を使えば、置き換えずにファイルへ書き足せる。
  • main() -> std::io::Result<()> にすると、呼び出しのたびに match しなくても main の中で直接 ? を使える。
  • e.kind() で ErrorKind::NotFound のようなエラーの種類がわかるので、種類ごとに違う対応ができる。
  • プロジェクトのフォルダにあるファイルを使う例は手元で試すのがいちばん。std::env::temp_dir() を使えば、どこでも動くフォルダが手に入る。

理解度チェック

1. すでに存在するパスに対して fs::write を呼ぶと、どうなりますか?

2. ファイルを上書きせずに書き足すには、どうしますか?

3. main 自体が std::io::Result<()> を返すようにすると、何ができるようになりますか?

4. fs::read_to_string(path)? が失敗したとき、? 演算子は何をしますか?

5. 大きなファイルを fs::read_to_string ではなく BufReader で1行ずつ読むのはなぜですか?

スコア: 0 / 5