ファイルの読み出しと書き込み――Rustのファイル入出力を理解する:基本からしっかり学ぶRust入門(15)
Rustについて基本からしっかり学んでいく本連載。第15回は、ファイルや標準入出力とのデータのやりとりについて。
ファイル入出力関連モジュール
Rustのファイル入出力については、第8回でその片りんを紹介しました。コマンドラインで受け取ったファイル名からそのファイルをオープンし、内容を読み込んだ上で表示するというシンプルなものでした。この例で見たように、Rustにおいてファイル入出力を扱うのはとても簡単です。
ファイル関連の機能を扱うのは、主にstd::fsモジュールとstd::ioモジュールの関数です。この2つのモジュールは、以下のような役割分担となっています。
- std::fsモジュール……ファイルのオープンや作成、ファイル/ディレクトリの操作
- std::ioモジュール……入出力関連のトレイト、標準入出力など
オープンなどファイルそのものの操作はstd::fsモジュールに集約され、オープンした結果のファイルハンドルを用いた入出力はstd::ioモジュールに集約されています。標準入出力など、アプリケーションがファイルのオープンとクローズに関与しない場合でも、std::ioモジュールのメソッドを使って入出力操作が可能です。
なお、例えばstd::netモジュールのTcpStream構造体によるネットワーク上の入出力も、std::ioモジュールのトレイトを利用します。このようにstd::ioモジュールのトレイトは、ファイル入出力に限定されませんが、今回はファイル入出力に絞って紹介していきます。
第8回と同様にサンプルのテキストファイルwagahaiwa_nekodearu.txtを用意して、それに対して操作しています。なお、今回のサンプルはiosパッケージとして作成していきます。
ファイルのオープン(作成)とクローズ
まずは、ファイル入出力の基本とも言える、オープンとクローズを見ていきます。
ファイルのオープンとクローズ
Rustにおいてファイルはstd::fsモジュールのFile構造体がつかさどります。第8回の例を今回用に少し手を入れて以下に示します。
use std::fs::File; (1) use std::io::Result; use std::io::prelude::*; fn main() -> Result<()> { (2) let filename = "wagahaiwa_nekodearu.txt"; let mut file = File::open(filename)?; (3) let mut contents = String::new(); file.read_to_string(&mut contents)?; println!("{}", contents); Ok(()) (4) }
(1)からはじまる3行は、モジュールのインポートです。このサンプルでは、std::fsモジュールのFile構造体、std::ioモジュールのResult列挙体、std::ioモジュール下のトレイトにある入出力関数(この場合はread_to_string)を使うので、このような宣言になります。以降、使用するモジュールや構造体、関数に応じてこの部分は変化していきますので、use文と対応する構造体とメソッドを対比してみてください。
(2)は、main関数がエラーを委譲されるという意味です。これにより、main関数中のエラーはmain関数を呼び出した側が引き受けます。本来は、個別の関数呼び出しでエラー処理すべきですが、簡略化のためにこの記法を用いています。詳しくは、第8回を参照してください。
(3)は、ファイルをオープンしてFile構造体のインスタンスを取得しています。File構造体とはファイルハンドルであり、これを用いてstd::ioモジュール内のトレイトに実装される関数で入出力を処理します。
(4)は、ここまで何の問題もなかったということで、Result列挙体のOk値を返す記述です。このパターンも、main関数の戻り値とともに以降のサンプルで共通です。
このサンプルにはファイルをクローズする部分がありませんが、File構造体のインスタンスを受けるfile変数がスコープから外れるとき、インスタンスの破棄とともに内部的にdrop関数が呼び出され、ファイルのクローズが実行されます。このため、ファイルクローズの明示的な指示は基本的に不要となっています。
【補足】preludeモジュール
上記のサンプル(1)で、3番目のuse文に含まれるpreludeモジュールは、利用頻度の高いトレイトの利用をまとめて宣言できる便利なモジュールで、この場合は続く「::*」によってBufRead、Seek、Read、Writeの4つのトレイトすべてを利用可能にします。「*」の部分をReadなどにすれば個別のトレイトのみに限定できますが、それだとpreludeを使う意味はあまりないので、「use std::io::Read;」と記述するようにします。
ファイルの作成
前項(3)のopen関数をcreate関数に変更すると、ファイルを作成してオープンします。ちなみにopen関数は、ファイルを読み込み専用でオープンしますので、ファイルへ書き込みたければcreate関数でファイルを作成します。以下は、ファイルを作成する例です。
let filename = "wagahaiwa_inudearu.txt"; let mut file = File::create(filename)?; file.write_all(b"I am a dog.\n")?;
write_allは書き込みのためのWriteトレイトの関数です。詳細は後述します。
ファイルモード(OpenOptions)
open関数は読み出し専用、create関数は書き込み専用でした。では、ファイルへの追加(append)はどうすればいいのでしょうか。このような場合にはOpenOptions構造体のインスタンスを生成して、それに対してオープンや作成を実行します。以下は、追加を表すappendモードを指定してファイルをオープンし、内容を追記する例です。
use std::fs::OpenOptions; (1) …中略… let filename = "wagahaiwa_inudearu.txt"; let mut options = OpenOptions::new(); (2) let mut file = options.append(true).open(filename)?; (3) file.write_all(b"I have no name, yet.\n")?;
(1)はuse文ですが、Fileモジュールの替わりにOpenOptionsモジュールを使うことを宣言しています。(2)では、そのOpenOptions構造体のインスタンスを生成し、(3)でappenndメソッドに引数にtrueを与えることで追加モードを有効にし、そしてopen関数を呼び出しています。なお、ここで呼び出しているopen関数は、File構造体のメンバではありません。あくまでもOpenOptions構造体のメンバであり、ほかにcreate、create_new、new、read、truncate、writeという関数があります。これらのメソッドは戻り値に自分自身を返すので、メソッドチェーンによってモードの追加や削除が可能になっています。
パスを扱うPath構造体
ここまで、open関数やcreate関数にはファイル名の文字列定数すなわち&strを引数に与えてきました。正式には、これらの関数はファイルパスを扱うPath構造体かPathBuf構造体のインスタンスを引数に受け取ることになっています。このPath構造体自体はファイルの読み書きに直接かかわるものではありませんが、軽く紹介しておきます。以下は、open.rsをPath構造体を使って書き直したものです。
use std::path::Path; (1) …中略… fn main() -> Result<()> { let filename = "wagahaiwa_nekodearu.txt"; let pathname = Path::new(filename); (2) let mut file = File::open(pathname)?; (3) …中略… }
(1)はPath構造体を使うためのuse文です。(2)は&strからPath構造体のインスタンスを生成し、(3)ではそれをopen関数に渡しています。ただ、単なるファイル名をopen関数に渡すのに、常にこのように記述しなければならないのは冗長なので、正確にはPath構造体ではなくAsRef<Path>トレイトを実装する型を引数に受け取ることになっています。
AsRef<Path>トレイトは、std::pathモジュールにてPath構造体や&strに対して実装されています。これにより、引数が&strであれば内部でPath構造体のインスタンスが生成されてそれが使用され、Path構造体であればそのまま使用される、という処理となります。
Path構造体は、パス名に対するさまざまな処理(連結、分割、判定など)のための関数を多数実装しており、そういった処理が必要な場合には非常に有用です。ただし、こうした処理が不要な場合にPath構造体を使うのはむしろ冗長なので、そのような場合には&strを直接渡すといったことも可能なわけです。
なお、Path構造体は基本的に不変のパスを扱うためのもので、書き換えを伴う場合にはPathBuf構造体を使用します。Path構造体とPathBuf構造体は、次回で詳しく紹介します。
ファイルの読み込み/書き込み/シーク
オープンしたファイルに対しては、主に読み出し、書き込み、そしてシークというファイル入出力の基本セットといった処理が可能です。
ファイルの読み込み/書き込み
まずは、オープンしたファイルに対しての読み込みと書き込みを紹介します。すでに、読み込みではread_to_string関数、書き込みではwrite_all関数を紹介していますが、読み込み系と書き込み系の関数は基本的にReadトレイトとWriteトレイトとしてFile構造体に実装されています。主なReadトレイトとWriteトレイトの関数は表1の通りです。戻り値の型は、実際にはResult<○○>型として返されます。
関数 | 概要 | 戻り値 |
---|---|---|
read | バッファーにu8(バイト)単位で読み込む | 読み込んだバイト数(usize型) |
read_vectored | 複数のバッファーににu8(バイト)単位で読み込む | 読み込んだバイト数(usize型) |
read_to_end | Vec<u8>型のバッファーへファイル終端まで読み込む | 読み込んだバイト数(usize型) |
read_to_string | ファイル全体をString型として読み込む | 読み込んだバイト数(usize型) |
read_exact | u8(バイト)型のバッファーを満たすように読み込む | なし |
write | バッファーの内容をu8(バイト)単位で書き込む | 書き込んだバイト数(usize型) |
flush | バッファーされている内容を直ちに書き込む | なし |
write_vectored | 複数のバッファーからu8(バイト)単位で書き込む | 書き込んだバイト数(usize型) |
write_all | u8(バイト)の配列の内容を全て書き込む | なし |
write_fmt | フォーマットされた文字列を書き込む | なし |
表1 ReadトレイトとWriteトレイトの主な関数 |
いずれも、read_to_string関数やwrite_fmt関数など一部を除いてバイト(u8)単位の読み書きです。いうなれば、バイナリファイルとしての扱いが標準です。また、OSのシステムコールを直接呼び出すといった低レベルな入出力を担うので、効率のよい入出力には後述するバッファー付きの読み書きと、標準入出力の特別な振る舞いの利用が推奨されます。これらは後述します。
ReadトレイトとWriteトレイトのサンプルは次節で紹介します。
カーソル位置のシーク
File構造体には、Seekトレイトが実装されていて、そのメソッドを使うとカーソル(ファイルの読み書き位置)のシークができます。主なSeekトレイトの関数は表2の通りです。戻り値の型は、実際にはResult<○○>型として返されます。
関数 | 概要 | 戻り値 |
---|---|---|
seek | SeekPos列挙型の引数で指定位置にシークする | シーク後の位置(u64型) |
rewind | ファイル先頭にシークする | なし |
stream_position | 現在のカーソル位置を返す | 現在の位置(u64型) |
表2 Seekトレイトの主な関数 |
seek関数の引数のSeekPos列挙体は、Start(u64)、End(i64)、Current(i64)の3つを列挙子として持ちます。それぞれ、先頭から、末尾から、現在位置からという意味で、値に指定位置からのオフセットを指定します。オフセットは、Startのみ符号なし整数であることに注意してください。
以下は、ファイルを作成してバイト列からなるメッセージを書き込み、カーソルをファイル先頭に戻して10バイト読み込んで表示する例です。
use std::fs::OpenOptions; use std::io::SeekFrom; (1) use std::io::prelude::*; fn main() -> Result<(), Box<std::io::Error>> { (2) let filename = "wagahaiwa_inudearu.txt"; let s = b"I am a dog.\n"; let mut options = OpenOptions::new(); let mut file = options.read(true).write(true).create(true).open(filename)?; (3) file.write(s)?; (4) file.seek(SeekFrom::Start(0))?; (5) let mut buf: [u8; 10] = Default::default(); (6) file.read(&mut buf)?; (7) println!("{:?}", buf); // ⇒ [73, 32, 97, 109, 32, 97, 32, 100, 111, 103] Ok(()) }
(1)は、seek関数でSeekFrom列挙体を使用するためのuse文です。
(2)は、これまでと委譲の内容が異なるので注意です。std::ioモジュール以下の入出力関数が異なった種類のエラーを返す場合のために、それをまとめて取り扱うためにBox<std::io::Error>を指定しています。Boxとはスマートポインタを表す型ですが、詳細は後続の回で紹介します。ここでは、複数のエラーを委譲するための書き方とだけ理解してください。また、std::io::Resultではなくstd::Resultになるのでuse文も不要となっています。
(3)はやや長いですが、ファイルを作成、読み書き可能でオープンするという意味です。create関数はファイル作成の関数ではなく、作成するというモードを表すことに注意してください。作成モードを指定されると、open関数はファイルが存在しなければ作成します。
(4)はsの内容を全て書き込み、(5)ではカーソルをファイルの先頭に戻し、(6)(7)で10バイトのバッファーに読み込んでいます。このように、バッファーを越えて読み込まれません。なお、(6)は固定長の配列を宣言し、デフォルト値で埋める場合の書式です。
バッファー付き読み書き
ここまで紹介した例は、ファイルをオープンしてバイト単位で読み込む、書き込むなどの操作でした。テキストファイルとして読み込むなら、read_to_string関数で一気に読み込んでいました。数KBほどの小さなファイルならさほど気にならないかも知れませんが、例えば数MBといったファイルを一気に読み書きするのは、メモリの消費量も大きいですし、必要でない分まで読み込んだりして効率のよいものではありません。テキストファイルなら1行ずつ読み込んで処理したいということもあるはずです。そこで、バッファー付きの読み書きのためのトレイトがあります。
バッファー付きの読み書きには、BufReadトレイトのBufReader構造体と、BufWriteトレイトのBufWriter構造体を使います。コンストラクタの引数にFile構造体のインスタンスを与えることで、そのファイルに対するバッファー付きの読み書きが可能です。
バッファー付きの読み出し
バッファー付きの読み出しでは、例えばlines関数によって行単位で読み込めます。以下は、ファイルから1行ずつ読み出し、行番号とともに表示する例です。
use std::fs::File; use std::io::{BufRead, BufReader}; (1) fn main() -> Result<(), Box<std::error::Error>> { let f = "wagahaiwa_nekodearu.txt"; let mut line_no = 1; for res in BufReader::new(File::open(f)?).lines() { (2) let line = res?; (3) println!("{}: {}", line_no, line); line_no = line_no + 1; } Ok(()) }
(1)は、BufReadトレイトとBufReader構造体のためのuse文ですが、使用するものを大かっこ「{ }」で囲んでいます。このように、使用したいものだけ列挙できます。
(2)は、BufReader構造体のインスタンスを作成し、BufReadトレイトのlinesメソッドで1行ずつ取り出す、というイテレータになっています。読み込んだ結果は変数resに入りますが、1行ずつになるのでメモリ消費を抑えられます。(3)では読み込んだ内容を表示用にいったん変数に移しています。
読み出しのための関数には、lines関数のほかに文字列へ1行読み出すread_line関数、指定文字が現れるまで読み出すread_until関数、指定文字を区切りとして各行へのイテレータを返すsplit関数などがあります。
バッファー付きの書き出し
バッファー付きの書き出しの例です。バッファー付きで読み込んだファイルの各行を、そのまま別ファイルに書き込んでいます。
use std::fs::File; use std::io::{BufRead, BufReader, Write, BufWriter}; fn main() -> Result<(), Box<std::error::Error>> { let sf = "wagahaiwa_nekodearu.txt"; let df = "cat.txt"; let mut bw = BufWriter::new(File::create(df)?); (1) for res in BufReader::new(File::open(sf)?).lines() { let line = res?; writeln!(bw, "{}", line); (2) } Ok(()) }
(1)では、BufWriter構造体のインスタンスを作成しています。コンストラクタの引数であるFile構造体インスタンスは、ファイルの作成なのでFile::createメソッドで生成しています。(2)は、writeln!マクロでBufWriterに書き込んでいます。writeln!マクロは、println!マクロのバッファー付き書き込み版のようなもので、第1引数に書き込み先を受け取るほかは、ほぼ同様に使用できます。
標準入出力の使用
std::ioモジュールには、標準入出力のための構造体が用意されています。標準入力はStdin構造体、標準出力はStdout構造体、標準エラー出力はStderr構造体となっています。ただし、これらはアプリケーション側でインスタンス化する必要はなく、生成済みのインスタンスをstdin関数、stdout関数、stderr関数でそれぞれ取得します。以下は、標準入力からの入力をEOF(End Of File、Ctrl-D)が入力されるまで、そのまま標準出力に書き出す例です。
use std::io::{self, Write}; (1) fn main() -> io::Result<()> { let mut buffer = String::new(); let stdin = io::stdin(); (2) let mut stdout = io::stdout(); loop { stdout.write_all(b"> "); (3) stdout.flush(); (4) match stdin.read_line(&mut buffer) { (5) Ok(0) => break, Ok(_) => { stdout.write_all(buffer.as_bytes())?; (6) buffer.clear(); } Err(_e) => break, } } Ok(()) }
(1)はuse文ですが、これまでとさらに異なった構文となっています。大かっこ({ }」で囲まれた中のselfは、「use std::io」の「io」そのものを表します。大かっこで囲んだもの以外にio自身も必要な場合には、このように記述します。
(2)は、ioモジュールのstdin関数で標準入力と標準出力のインスタンスを取得しています。(3)は既出のwtiteメソッドで、プロンプトである「>」を出力しています。文字列リテラルに前置された「b」は、文字列をUTF-8のバイト列であることを修飾しています。
(4)は、入力待ちになる前にプロンプトを表示するように、書き込みバッファーに残っているものを書き出しています。(5)のread_line関数はStdin構造体の関数で、改行まで読み込んで文字列変数に格納しています。
(6)は、これも既出のwrite_all関数で書き込んでいますが、文字列変数にas_bytes関数を適用して、バイト列として書き込んでいます。
まとめ
今回は、標準入出力を含むファイルの入出力を紹介しました。
次回は、オープンや作成以外のstd::fsモジュールのメソッドによるファイル/ディレクトリの操作、std::pathモジュールの構造体によるパスの操作を紹介します。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.