本記事は、Rust and CSV parsing - Andrew Gallant's Blogの翻訳です。原文のライセンスはMIT or UNLICENSEです。
翻訳について
非常に長い記事なので、なぜこれを翻訳したのかということについて説明します。
本記事はRustのrust-csv
の使い方についてのチュートリアルですが、むしろRustの入門として有用です。ここではrust-csv
の使い方だけでなく、エラー処理、borrowingやlifetimeの基本、Serdeの使い方、プログラムをパフォーマンスのよいコードに書き換える方法などについて言及されています。内容はいずれも初歩的ですが、Rustで安全かつ高速なプログラミングを行う上での中心的な要素と言えます。
SerdeはRustのシリアライゼーションフレームワークです。このライブラリは、Rustでシリアライゼーションする上でのデファクトスタンダードなのですが、日本語解説はまだ少ないように思えます。この点でも、本記事は単にCSVをRustで解析する以上の知識を我々に与えてくれるでしょう。
非常に長い記事ですが、それは初心者にも分かりやすいように、(少しくどいほど)丁寧に説明したことによるものです。記事自体はステップ・バイ・ステップで、楽に読み進められるようになっています。
本記事は私のブログからの転載です。
本文
ついにcsv 1.0
のベータ版がリリースされ1 、RustでCSVを読み込み書き込みするチュートリアルを書くにはちょうどよいころ合いになった。このチュートリアルは初心者Rustプログラマを対象としている。よってここでは豊富な例を取り上げ、基本的な概念の説明にも紙幅を割いた。熟達したRustプログラマにとっては、使える部分もあるかもしれないが、この点は軽く読み流したほうが得策であろう。
Rustのイントロは公式のオンライン本を参照してほしい。もしあなたが他の言語経験のあるRust入門者なら、他の予備知識を必要とせずにこの本をいきなり読んでも大丈夫だろう。
CSVライブラリはGitHubから入手可能で包括的なドキュメントも存在する。
最後に、このブログポストはAPIドキュメントのチュートリアルに含まれており、時間とともに更新される余地があることを承知いただきたい。
対象読者 : 初心者のRustプログラマ
CSV 1.0 beta リリース
チュートリアルに入る前に、手短にこのライブラリが1.0
に到達するまでの道のりを語らせてほしい。rust-csv
リポジトリへの最初のコミット日は2014年3月22日である。これはRust言語のバージョン1.0がリリースされる一年ちょっと前のことである。Rust 1.0以前から関わっていた人々にとって、この言語にどれほど多くの変更が行われてきたか、というのは記憶に鮮やかなところだろう。もちろん、作者も同様に変化に追従することで、この言語に親しみイディオムに精通していった。しかしながら、CSVライブラリのAPIはほとんど最初のバージョンから変更されることはなかった。当時のAPIはパフォーマンスを向上させるのが難しく、いくつかのすさまじいバグを抱えており、そしてもっと悪いことに、古いシリアライゼーション基盤2を利用していた。
CSV 1.0はライブラリとしての高速化を達成した。またSerdeというシリアライゼーションフレームワークの下支えを受け、より良いAPIを手に入れた。
新しいCSVライブラリにはcsv-core crateが同梱されている。これはRustの標準ライブラリに頼らずCSVをパースすることができ、またパフォーマンスの改善について大部分の責任を負うライブラリである。とりわけ、古いCSVライブラリはある種の有限状態機械を用いており、これが大きなオーバーヘッドとなっていた。csv-core
crateはそのパーサをテーブルに基づくDFAにコンパイルする。これは数百バイト程度しかスタックを使わない。結果として、我々は全体的に約2倍の改善を得ることができた:
count_game_deserialize_owned_bytes 30,404,805 (85 MB/s) 23,878,089 (108 MB/s) -6,526,716 -21.47% x 1.27
count_game_deserialize_owned_str 30,431,169 (85 MB/s) 22,861,276 (113 MB/s) -7,569,893 -24.88% x 1.33
count_game_iter_bytes 21,751,711 (119 MB/s) 11,873,257 (218 MB/s) -9,878,454 -45.41% x 1.83
count_game_iter_str 25,609,184 (101 MB/s) 13,769,390 (188 MB/s) -11,839,794 -46.23% x 1.86
count_game_read_bytes 12,110,082 (214 MB/s) 6,686,121 (388 MB/s) -5,423,961 -44.79% x 1.81
count_game_read_str 15,497,249 (167 MB/s) 8,269,207 (314 MB/s) -7,228,042 -46.64% x 1.87
count_mbta_deserialize_owned_bytes 5,779,138 (125 MB/s) 3,775,874 (191 MB/s) -2,003,264 -34.66% x 1.53
count_mbta_deserialize_owned_str 5,777,055 (125 MB/s) 4,353,921 (166 MB/s) -1,423,134 -24.63% x 1.33
count_mbta_iter_bytes 3,991,047 (181 MB/s) 1,805,387 (400 MB/s) -2,185,660 -54.76% x 2.21
count_mbta_iter_str 4,726,647 (153 MB/s) 2,354,842 (307 MB/s) -2,371,805 -50.18% x 2.01
count_mbta_read_bytes 2,690,641 (268 MB/s) 1,253,111 (577 MB/s) -1,437,530 -53.43% x 2.15
count_mbta_read_str 3,399,631 (212 MB/s) 1,743,035 (415 MB/s) -1,656,596 -48.73% x 1.95
count_nfl_deserialize_owned_bytes 10,608,513 (128 MB/s) 5,828,747 (234 MB/s) -4,779,766 -45.06% x 1.82
count_nfl_deserialize_owned_str 10,612,366 (128 MB/s) 6,814,770 (200 MB/s) -3,797,596 -35.78% x 1.56
count_nfl_iter_bytes 6,798,767 (200 MB/s) 2,564,448 (532 MB/s) -4,234,319 -62.28% x 2.65
count_nfl_iter_str 7,888,662 (172 MB/s) 3,579,865 (381 MB/s) -4,308,797 -54.62% x 2.20
count_nfl_read_bytes 4,588,369 (297 MB/s) 1,911,120 (714 MB/s) -2,677,249 -58.35% x 2.40
count_nfl_read_str 5,755,926 (237 MB/s) 2,847,833 (479 MB/s) -2,908,093 -50.52% x 2.02
count_pop_deserialize_owned_bytes 11,052,436 (86 MB/s) 8,848,364 (108 MB/s) -2,204,072 -19.94% x 1.25
count_pop_deserialize_owned_str 11,054,638 (86 MB/s) 9,184,678 (104 MB/s) -1,869,960 -16.92% x 1.20
count_pop_iter_bytes 6,190,345 (154 MB/s) 3,110,704 (307 MB/s) -3,079,641 -49.75% x 1.99
count_pop_iter_str 7,679,804 (124 MB/s) 4,274,842 (223 MB/s) -3,404,962 -44.34% x 1.80
count_pop_read_bytes 3,898,119 (245 MB/s) 2,218,535 (430 MB/s) -1,679,584 -43.09% x 1.76
count_pop_read_str 5,195,237 (183 MB/s) 3,209,998 (297 MB/s) -1,985,239 -38.21% x 1.62
では寄り道はこれくらいにして、はじめて行こう。
セットアップ
本節では、単純なプログラムでCSVを読み込み、そして「デバッグ」形式でそれぞれのレコードを表示する。本節はRustツールチェイン(Rust本体とCargo)がすでにインストールされていることを前提としている。
Cargoで新しいプロジェクトを作成する:
$ cargo new --bin csvtutor
$ cd csvtutor
csvtutor
ディレクトリの内部に降り、Cargo.toml
をお好きなテキストエディタで開いていただき、csv = "1.0.0-beta.5"
を[dependencies]
セクションに追加して欲しい。ここまでで、Cargo.toml
は以下のようになっているはずだ。
[package]
name = "csvtutor"
version = "0.1.0"
authors = ["Your Name"]
[dependencies]
csv = "1.0.0-beta.1"
つづいてこのプロジェクトをビルドしてみよう。csv
crateを依存関係に追加したので、Cargoは自動的にそれをダウンロードしてコンパイルしてくれる。Cargoよりプロジェクトを以下のようにビルドする:
$ cargo build
このコマンドはcsvtutor
という新しいバイナリをtarget/debug
ディレクトリに作成する。この時点ではあまり役に立たないが、これは実行することができる:
$ ./target/debug/csvtutor
Hello, world!
このプログラムを何か役に立つことをするものにしてやろう。最初のプログラムはCSVデータをstdinから読み込み、それぞれのレコードをstdoutにデバッグ出力を行う。そのようなプログラムを書くためには、src/main.rs
を開いて内容を次のように書き換える。
//tutorial-setup-01.rs
// これはcsv crateをプログラムから利用可能にする
extern crate csv;
// 標準ライブラリのI/Oモジュールをインポートしてstdinからの読み込みをできるようにする
use std::io;
// `main`関数はプログラムの実行が始まるところである
fn main() {
// CSVパーサを生成しstdinからデータを読む
let mut rdr = csv::Reader::from_reader(io::stdin());
// それぞれのレコード上をループする
for result in rdr.records() {
// もしエラーが起こったら、プログラムを不親切に中止(abort)する
// ここはあとでより丁寧なチェックを行う
let record = result.expect("a CSV record");
// レコードをデバッグ形式で出力する
println!("{:?}", record);
}
}
以上のコードが何を意味するかということについて過度な不安を抱かないでほしい;次節で詳しく解説する。さしあたりは、プロジェクトをリビルドしてみてほしい;
$ cargo build
ビルドが成功したと仮定して、プログラムを実行してみよう。ただし、まずは何か遊べるCSVデータが必要だ! というわけで、アメリカの100都市を適当に抽出して人口と地理座標を合わせたデータを使うことにする(このCSVデータはチュートリアルを通して利用する)。データはgithubから次のコマンドでダウンロードする。
$ curl -LO 'https://raw.githubusercontent.com/BurntSushi/rust-csv/master/examples/data/uspop.csv'
最終的に、プログラムにuspop.csv
を与えて次のように実行する。
$ ./target/debug/csvtutor < uspop.csv
StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])
StringRecord(["Oakman", "AL", "", "33.7133333", "-87.3886111"])
# ... and much more
エラー処理の基本
CSVデータの読み込みはエラーに終わることがあるため、エラー処理はこのチュートリアルのコード例ではいたるところで行われることになる。それゆえに、多少なりともの時間をエラー処理の基本に割くことになる。特に、前節のコード例をより親切な形のエラーを表示するように修正する。もしあなたがすでにRustのReuslt
やtry!/?
に慣れ親しんでいるなら、この節は読み飛ばしてかまわない。
留意してほしいのは、The Rust Programming Language Bookは一般的なエラー処理のイントロを内容として含んでいるという点である。より深く掘り下げるには、エラー処理に関する私のブログポストを参照してほしい。このブログポストは、特にRustのライブラリを設計するときに重要である。
Rustのエラー処理には2つの異なった形式がある:回復不可能なエラーと回復可能なエラーである。
回復不可能なエラーとは一般的に、不変性や約束ごと(contract)が壊れたときに起こる可能性のある、プログラム中のバグに該当する。そのような場合は、プログラムの状態は予測不可能であり、パニックするほかに頼みとするものがほとんどない。Rustでは、パニックは単純にプログラムの中断(aborting)と似通っており、違いはプログラムが終了する前にスタックをアンワインドし、リソースをクリーンアップするものであるということだ。
対して、回復可能なエラーは、一般的に予測可能なエラーに該当する。存在しないファイルや、正しくないCSVデータの取り扱い時に起こるエラーは回復可能なエラーの例である。Rustでは、回復可能なエラーはResult
により処理される。Result
は成功または失敗どちらかの計算の状態を表現している。これは次のように定義される:
enum Result<T, E> {
Ok(T),
Err(E),
}
すなわち、Result
は計算が成功したとき型T
の値を含み、計算が失敗したときは型E
の値を含む。
回復不可能なエラーと回復可能なエラーの関係は重要である。特に、回復可能なエラーを回復不可能なものとして扱うことはまったく推奨されない。例として、ファイルが見つからなかったときや、正しくないCSVであったときにパニックすることは、悪い習慣と考えられている。パニックせずに、予測可能なエラーはResult
型を用いて処理するべきである。
ここまでで得た知識により、前回の例を見直しエラー処理を詳細化してみよう:
[まず元のコードは以下のようになる。]
//tutorial-error-01.rs
extern crate csv;
use std::io;
fn main() {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.records() {
let record = result.expect("a CSV record");
println!("{:?}", record);
}
}
このプログラムには、エラーが起こりそうなところが2カ所ある。第一にstdinからレコードを読み込むときに問題があった場合。第二に、stdoutへの書き込みに問題がある場合である。通例にしたがい、このチュートリアルでは後者の問題は無視することにするが、堅牢なコマンドラインアプリケーションを作ろうとするときは処理を怠らないようにしたほうがいい(例:broken pipeが起こったとき)。前者のエラーは詳細を調べる価値がある。例として、もしプログラムのユーザが間違ったCSVを与えたとき、プログラムはパニックを起こす:
$ cat invalid
header1,header2
foo,bar
quux,baz,foobar
$ ./target/debug/csvtutor < invalid
StringRecord { position: Some(Position { byte: 16, line: 2, record: 1 }), fields: ["foo", "bar"] }
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: UnequalLengths { pos: Some(Position { byte: 24, line: 3, record: 2 }), expected_len: 2, len: 3 }', /checkout/src/libcore/result.rs:859
note: Run with `RUST_BACKTRACE=1` for a backtrace.
何が起こったのだろうか? 真っ先に話すべきことは、なぜこのCSVデータが正しくないのかということである。このCSVデータは3つのレコードから成っている:1つのヘッダと2つのデータレコードである。ヘッダとひとつ目のデータレコードは2つのフィールドを持っているが、ふたつ目のデータレコードは3つのフィールドを持っている。デフォルトでは、csv
crateは一貫性のないレコード長をエラーとして取り扱う(この挙動はReaderBuilder::flexible
の設定をいじることにより切り替えることができる)。この例は、なぜ最初のデータレコードがこのプログラムで表示できたのかを説明する。すべてヘッダと同じ長さのデータフィールドを持っていたからである。言い換えると、2行目のデータレコードをパースするまでは、実際にエラーに当たるかどうかは分からないのである。
(留意しておいていただきたいのは、CSVリーダーは自動的に最初のレコードをヘッダとして解釈する点である。これについてはReaderBuilder::has_headers
の設定から切り替えることができる。)
実際にこのプログラムでパニックを引き起こしているものは何か? 答えはループの1行目にある:
for result in rdr.records() {
let record = result.expect("a CSV record"); // this panics
println!("{:?}", record);
}
ここで重要な点は、rdr.records()
がResult
値を生じる(yields)イテレータを返すということである。すなわち、それはレコードそのものを生み出す代わりに、レコードまたはエラーを含むResult
を生じるということである。Result
上で定義されているexpect
メソッドは、Result
中の成功した値を取り出す(unwrap)。実際にはResult
にエラーが含まれていることもあるので、そのような場合にexpect
を呼ぶとプログラムはパニックを起こす。
expect
の実装を読んでみるとよい。
use std::fmt;
// これは"全ての型T, Eについて、Eは可読なデバッグメッセージになるものでは、
// `expect`メソッドが定義されている"ということを言っている
// This says, "for all types T and E, where E can be turned into a human
// readable debug message, define the `expect` method."
impl<T, E: fmt::Debug> Result<T, E> {
fn expect(self, msg: &str) -> T {
match self {
Ok(t) => t,
Err(e) => panic!("{}: {:?}", msg, e),
}
}
}
これはCSVデータが正しくない場合パニックを引き起こす。また正しくないCSVデータの読み込みは完全に予測可能なエラーであるのに、ここでは回復可能なエラーを回復不可能なものにしてしまっている。これは回復不可能なエラーをその場しのぎの方法で用いている。そして、それはバッドプラクティスである。ゆえに以降のチュートリアルでは回復不可能なエラーを避けるよう努めていく。
回復可能なエラーに切り替える
3ステップに分けて回復不可能なエラーを回復可能なエラーに変換する。最初に、パニックを取り除いて手作業でエラーメッセージを表示する。
//tutorial-error-02.rs
extern crate csv;
use std::io;
use std::process;
fn main() {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.records() {
// Examine our Result.
// If there was no problem, print the record.
// Otherwise, print the error message and quit the program.
match result {
Ok(record) => println!("{:?}", record),
Err(err) => {
println!("error reading CSV from <stdin>: {}", err);
process::exit(1);
}
}
}
}
もう一度プログラムを実行すると、いまだにエラーメッセージは現れれるものの、それはパニック時のメッセージではなくなっている:
$ cat invalid
header1,header2
foo,bar
quux,baz,foobar
$ ./target/debug/csvtutor < invalid
StringRecord { position: Some(Position { byte: 16, line: 2, record: 1 }), fields: ["foo", "bar"] }
error reading CSV from <stdin>: CSV error: record 2 (line: 3, byte: 24): found record with 3 fields, but the previous record has 2 fields
回復可能なエラーに向かう第2のステップはCSVレコードに対するループを関数に分離することである。この関数はオプションとしてエラーを返し、main
関数はエラーを調査しどのようにそれを扱うか決めることができる:
//tutorial-error-03.rs
extern crate csv;
use std::error::Error;
use std::io;
use std::process;
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.records() {
// 結果を観察しよう。
// もし問題がなければ、レコードを印字する。
// そうでなければ、エラーを`Box<Error>`に変換して返す。
match result {
Err(err) => return Err(From::from(err)),
Ok(record) => {
println!("{:?}", record);
}
}
}
Ok(())
}
新しく定義した関数run
は、返り値の型Result<(), Box<Error>
を持っている。簡単にいうと、run
は成功したときには何も返さず、エラーが起きた時は、いかなる種類のエラーをも表すBox<Error>
を返す。エラーの詳細に関心があるとき、Box<Error>
では何が起こったのか調べるのが難しくなる。しかし目先の問題としては、うやうやしくエラーメッセージを表示しプログラムを終了することでこと足りるのでこれ以上は求めない。
第3の、最後のステップは、明示的なmatch
式を、Rustの特有の言語仕様であるクエスチョンマーク(?
)で置き換えることである。
//tutorial-error-04.rs
extern crate csv;
use std::error::Error;
use std::io;
use std::process;
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.records() {
// これは実際には前コード例の`match`と同等である。
// 言い換えると`?`はシンタックスシュガーである。
let record = result?;
println!("{:?}", record);
}
Ok(())
}
この最後のステップは、match
で明示的に場合分けを行うことなく、?
を使って関数の呼び出し元に自動的にエラーを送出する方法を示している。このチュートリアルでは?
を使い倒すが、重要なのは、?
は**Result
型を返す関数の中でのみ使うことができる**という点である。
これで本節を終えるが一つ注意書きを:ここで使ったBox<Error>
は、我々が許容できる最低限度のエラーに過ぎないということである。すなわち、これはプログラムに正常にエラー処理をさせてはいるが、実際にエラーが起きた時、詳細なエラー状態を呼び出し側から調べることが難しい。このチュートリアルで書くのはCSVをパースするコマンドラインプログラムなので、これで問題ないと考える。この辺りについてより深く知りたい人、あるいはCSVデータを処理するライブラリ作りに興味のある向きは、エラー処理に関する私のブログポストを参考にしてほしい。
そんなわけで、もしあなたがCSVを変換するちょっとしたプログラムを書く程度ならば、expect
のようなメソッドを使ってエラーが起こった時はパニックを起こす、というのも完全に合理的なやり方であると言える。
しかしながら、このチュートリアルでは[よい習慣を身につけてもらうため]イディオマティックなコードを示すように努める。
CSVの読み込み
これで基本的なセットアップとエラー処理の説明が終わり、ようやくやりたいことができるようになった:CSVデータの処理である。すでにstdin
からCSVデータを読み込む方法は見てきたので、この節では、ファイルからのCSVデータの読み込み方と、異なる区切り文字や、クォーティングの戦略に合わせてCSVリーダーをどのように設定するかについてカバーする。
まず最初に、これまでの例をstdin
の代わりにファイルパス引数を受け取るように変更する。
//tutorial-read-01.rs
extern crate csv;
use std::env;
use std::error::Error;
use std::ffi::OsString;
use std::fs::File;
use std::process;
fn run() -> Result<(), Box<Error>> {
let file_path = get_first_arg()?;
let file = File::open(file_path)?;
let mut rdr = csv::Reader::from_reader(file);
for result in rdr.records() {
let record = result?;
println!("{:?}", record);
}
Ok(())
}
/// このプロセスに送られた最初の固定引数を返す
/// 固定引数がなかった場合エラーを返す。
fn get_first_arg() -> Result<OsString, Box<Error>> {
match env::args_os().nth(1) {
None => Err(From::from("expected 1 argument, but got none")),
Some(file_path) => Ok(file_path),
}
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
src/main.rs
のコードを上のように書き換えたら、プロジェクトをリビルドしてテストデータに使ってみてほしい。
$ cargo build
$ ./target/debug/csvtutor uspop.csv
StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])
StringRecord(["Oakman", "AL", "", "33.7133333", "-87.3886111"])
# ... and much more
このコード例は2つの新しい部分からなっている:
- ある固定引数を照会するためのコードが追加された。
get_first_arg
である。このプログラムはファイルパスとなる第一引数を期待し(これは添え字1
で取り出せる:引数の添え字0
には実行ファイル名が入っている)、もし引数が存在しなければget_first_arg
はエラーを返す。 - ファイルを開くコードが追加された。
run
の中でFile::open
を用いてファイルを開き、もしファイルを開く際に問題が起これば、run
の呼び出し元にエラーを送出する(ここではこのプログラムのmain
)。留意すべきは、我々はファイルをバッファで包むようなことはしていないということである。ファイルのバッファリングはCSVリーダーが内部的に行うので、呼び出し側がそれを行う必要はないのである。
ここでもうひとつ、CSVリーダーの別のコンストラクタを紹介しておこう。ファイルからCSVデータを開くのにこれまでより若干便利にしてくれるものである。それは以下の部分を
let file_path = get_first_arg()?;
let file = File::open(file_path)?;
let mut rdr = csv::Reader::from_reader(file);
次のように書き換える。
let file_path = get_first_arg()?;
let mut rdr = csv::Reader::from_path(file_path)?;
csv::Reader::from_path
はファイルをCSVデータとして開き、もしファイルを開けなかったときはエラーを返す。
ヘッダの読み出し
uspop.csv
の中身を見てみると、ヘッダレコードが次のようになっていることに気がつくだろう。
City,State,Population,Latitude,Longitude
いままでに実行したコマンドの出力に立ち返ってみると、ヘッダレコードが決して出力されていないことに気づいただろう。なぜそうなるのか? デフォルト設定では、CSVリーダーはCSVデータの最初のレコードをヘッダとして解釈し、それ以下の行のレコード中の実データとは区別して扱うようにしているからである。それゆえに、CSVデータを読み込みレコードを舐めようとするときは、常にヘッダがスキップされるようになっている。
CSVリーダーはヘッダレコードに関して賢く振る舞おうとはせず、最初のレコードがヘッダであるかどうかを自動的に判別するために、なにか発見的な手法を用いていたり、などということは全くない。代わりに、もし最初のレコードをヘッダとして取り扱いたくないときは、CSVリーダーに対して明示的にそれはヘッダでないことを伝えてやる必要がある。
そのような望み通りの設定をCSVリーダーに対して行うために、ReaderBuilder
を用いる必要がある。以下に使った例を示す(注意:コードはstdin
から読み出すものに戻っているが簡単のためである)
//tutorial-read-headers-01.rs
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::ReaderBuilder::new()
.has_headers(false)
.from_reader(io::stdin());
for result in rdr.records() {
let record = result?;
println!("{:?}", record);
}
Ok(())
}
このプログラムをコンパイルしてuspop.csv
とともに実行すると、ヘッダレコードが表示されたのが確認できるだろう:
$ cargo build
$ ./target/debug/csvtutor < uspop.csv
StringRecord(["City", "State", "Population", "Latitude", "Longitude"])
StringRecord(["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])
StringRecord(["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])
StringRecord(["Oakman", "AL", "", "33.7133333", "-87.3886111"])
もしヘッダレコードを直接読み出したいときは、Reader::header
メソッドを使ってこのようにする:
//tutorial-read-headers-02.rs
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
{
// lifetimeのために、この呼び出しはそれ所有スコープでネストされている。
let headers = rdr.headers()?;
println!("{:?}", headers);
}
for result in rdr.records() {
let record = result?;
println!("{:?}", record);
}
// ここでヘッダーはいつでも呼び出すことができる。この呼び出しは所有スコープでネストする必要はない。
// なぜならCSVリーダーをもう一度borrowしようとしてないからである。
let headers = rdr.headers()?;
println!("{:?}", headers);
Ok(())
}
ひとつの興味深いのは、rdr.headers()
の呼び出しがその所有スコープ(its own scope)の中で呼び出されている点である。なぜこのような入れ子を作るのかというと、rdr.headers()
はCSVリーダーの内部のヘッダ状態のborrow3を返すからである。このコードにおいてネストしたスコープは、レコードをイテレートしようとする前に、borrowを終了することを可能たらしめている。もしrdr.header()
をその所有スコープにネストさせることを怠れば、コードはコンパイルできない。なぜなら、CSVリーダーのヘッダからのborrowと、CSVリーダーがレコード上をイテレートしようとするとき必要なborrowを、同時に行うことはできないからである。
borrowの問題に対する別解としては、ヘッダをclone
すればよいというものがある:
let headers = rdr.headers()?.clone();
このコードはCSVリーダーからのborrowを新しいowned valueに変換する。この解決法はコードをわずかに読みやすくするが、ヘッダレコードを新しく割り当てたメモリにコピーするというコストを払わなければならない。
デリミタ、クォートそして可変長レコード
この節では一時的にuspop.csv
のことは忘れ、他のあまりきれいでないCSVデータの読み込み方を示す。以下のCSVデータは;
をデリミタとして使い、クォートを\"
でエスケープしている。そしてこのCSVデータはレコード長がバラバラである。データの内容は、WWEのプロレスラーのリストと、そのデビュー年(もし分からない場合は欠落データとなる)を含むものである。
$ cat strange.csv
"\"Hacksaw\" Jim Duggan";1987
"Bret \"Hit Man\" Hart";1984
# We're not sure when Rafael started, so omit the year.
Rafael Halperin
"\"Big Cat\" Ernie Ladd";1964
"\"Macho Man\" Randy Savage";1985
"Jake \"The Snake\" Roberts";1986
このCSVデータを読み込むために、以下のようなことをしたい。
- ヘッダ読み込みを無効化する。このデータにはヘッダがない。
- デリミタを
,
から;
に変更する。 - クォート戦略を2つ囲み(e.g.,
""
)からエスケープ(e.g.,\"
)に変更する。 - 年が省略されていてもよいよう、柔軟なレコード長を許可する。
-
#
から始まる行を無視する(コメント)
これらすべてはReaderBuilder
から設定することができる(実際にはそれ以上のことができる)。以下に例を示す:
//tutorial-read-delimiter-01.rs
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::ReaderBuilder::new()
.has_headers(false)
.delimiter(b';')
.double_quote(false)
.escape(Some(b'\\'))
.flexible(true)
.comment(Some(b'#'))
.from_reader(io::stdin());
for result in rdr.records() {
let record = result?;
println!("{:?}", record);
}
Ok(())
}
再コンパイルしてstrange.csv
に対してプログラムを走らせてみる:
$ cargo build
$ ./target/debug/csvtutor < strange.csv
StringRecord(["\"Hacksaw\" Jim Duggan", "1987"])
StringRecord(["Bret \"Hit Man\" Hart", "1984"])
StringRecord(["Rafael Halperin"])
StringRecord(["\"Big Cat\" Ernie Ladd", "1964"])
StringRecord(["\"Macho Man\" Randy Savage", "1985"])
StringRecord(["Jake \"The Snake\" Roberts", "1986"])
設定周りで少し遊んでみたくなったのではないだろうか。次のようなことを試してみると面白いかもしれない:
-
escape
の設定を削除しても、CSVの読み込みについてなんらエラーが報告されないことに気づくだろう。レコードはそれでもなおパースできている。これはCSVパーサーの仕様である。与えられたデータが少し間違っていても、使えそうなデータにパースを行なってくれるのである。これはとっちらかった現実世界のCSVデータを取り扱う上で、便利な性質である。 -
delimiter
の設定を削除してもパースは成功する、しかしすべてのレコードは一つのフィールドしか持たない。 -
flexible
の設定を外すと、CSVリーダーは最初の2つのレコードを表示し(それぞれのフィールドは同数である)、それからフィールドが一つしかない3つめのレコードでパースエラーを返す。
この節でCSVリーダーを設定するのに必要な大部分はカバーすることができた。とはいえここにはまだ紹介していない設定項目がいくつかある。例として、レコードの終端記号を改行文字から別の文字に変更することもできる(デフォルトでは、終端記号はCRLF
である。これは\r\n
と\n
を一つのレコードの終端記号としてあつかう)。より詳くは、ドキュメントとReaderBuilder
のそれぞれのメソッドを参考にしてほしい。
Serdeとともに読み込む
csv
crateのもっとも便利な特徴は、Serde
をサポートしているという点である。Serdeは、データを自動的にシリアライズとデシリアライズして、Rustの型に落とし込むためのフレームワークである。もっと簡単にいうと、文字列フィールドの配列としてレコードをイテレーションする代わりに、我々が選んだ特定の型のレコードをイテレーションすることができる、ということである。
例として、uspop.csv
からいくつかデータを見てみよう
City,State,Population,Latitude,Longitude
Davidsons Landing,AK,,65.2419444,-165.2716667
Kenai,AK,7610,60.5544444,-151.2583333
いくつかのフィールドは文字列として意味をなす(City
, State
)が、他のフィールドは文字列というより数値であるように思えるだろう。例として、Population
は整数を含んでおり、Lattitude
とLongitude
は小数を含んでいるようだ。もしこれらのフィールドを適切な型に変換したい思ったら、多くの手作業を必要とするだろう。次の例はそれを示す。
//tutorial-read-serde-01.rs
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.records() {
let record = result?;
let city = &record[0];
let state = &record[1];
// Some records are missing population counts, so if we can't
// parse a number, treat the population count as missing instead
// of returning an error.
let pop: Option<u64> = record[2].parse().ok();
// Lucky us! Latitudes and longitudes are available for every record.
// Therefore, if one couldn't be parsed, return an error.
let latitude: f64 = record[3].parse()?;
let longitude: f64 = record[4].parse()?;
println!(
"city: {:?}, state: {:?}, \
pop: {:?}, latitude: {:?}, longitude: {:?}",
city, state, pop, latitude, longitude);
}
Ok(())
}
ここでの問題は、それぞれのフィールドを手作業でパースせざるをえなくなっていることであり、これは大変な労力と繰り返し作業を要求するものになりうる。Serdeはこの手順を自動化する。例として、すべてのレコードをタプル型にデシリアライズすることができる。
//tutorial-read-serde-02.rs
// 型エイリアスでレコードの型を簡易に参照している
type Record = (String, String, Option<u64>, f64, f64);
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
// `record`でイテレータを作る代わりに
// `deserialize`でイテレータを作る
for result in rdr.deserialize() {
// We must tell Serde what type we want to deserialize into.
// Serdeに対してどんな型にデシリアライズしてほしいか
// 教えてあげる必要がある
let record: Record = result?;
println!("{:?}", record);
}
Ok(())
}
このコードを実行すると前の例と似たような出力が得られる:
$ cargo build
$ ./target/debug/csvtutor < uspop.csv
("Davidsons Landing", "AK", None, 65.2419444, -165.2716667)
("Kenai", "AK", Some(7610), 60.5544444, -151.2583333)
("Oakman", "AL", None, 33.7133333, -87.3886111)
# ... and much more
Serdeを使う上で1つの不都合な点は、指定したレコードの型が、実際のそれぞれのレコードの順序と一致している必要があるということである。これはCSVデータがヘッダレコードを持っているとき、それぞれのフィールドを数値付きフィールドではなく特定の名前付きフィールドとして考えがちになるため、苦痛となりうる。一つの方策としてHashMap
やBTreeMap
を用いてレコードをmap型にデシリアライズするというのがある。次の例は、とりわけ注意すべきところとして、前の例からRecord
型エイリアスを変更してuse
でHashMap
をインポートしたところ変わったに過ぎないことを注意されたい。
//tutorial-read-serde-03.rs
use std::collections::HashMap;
// This introduces a type alias so that we can conveniently reference our
// record type.
type Record = HashMap<String, String>;
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.deserialize() {
let record: Record = result?;
println!("{:?}", record);
}
Ok(())
}
このプログラムを実行すると前と似通っているが、それぞれのレコードはmapとして出力されている。
$ cargo build
$ ./target/debug/csvtutor < uspop.csv
{"City": "Davidsons Landing", "Latitude": "65.2419444", "State": "AK", "Population": "", "Longitude": "-165.2716667"}
{"City": "Kenai", "Population": "7610", "State": "AK", "Longitude": "-151.2583333", "Latitude": "60.5544444"}
{"State": "AL", "City": "Oakman", "Longitude": "-87.3886111", "Population": "", "Latitude": "33.7133333"}
この方法は、特にCSVデータをヘッダレコードとともに読み出すときに使えるが、実際のデータ構造はプログラムを走らせるまで分からない。しかしながら、このケースではuspop.csv
のデータ構造は事前に分かっている。特に、HashMap
を使ったアプローチは、前のコード例でそれぞれのフィールドを(String, String, Option<u64>, f64, f64>
にデシリアライズしたときと比べて、型の詳細情報を失っている。ヘッダ名から対応するフィールドを割り出し、それぞれのフィールドに一意な型をつける方法があるのだろうか? 答えはイエスであるが、serde_derive
という新たなcrateを導入する必要がある。Cargo.toml
の[dependencies]
に以下を追加する:
serde = "1"
serde_derive = "1"
これらのcratesをプロジェクトに追加することで、レコードを表現するカスタム構造体を定義することができるようになる。これでCSVレコードを自作の構造体に落とし込むようなグルーコードを、Serdeに自動的に導出してもらうことができる。以下に例を示す。書き写すときは新しくextern crate
するのを忘れないように!
//tutorial-read-serde-04.rs
extern crate csv;
extern crate serde;
// This lets us write `#[derive(Deserialize)]`.
#[macro_use]
extern crate serde_derive;
use std::error::Error;
use std::io;
use std::process;
// We don't need to derive `Debug` (which doesn't require Serde), but it's a
// good habit to do it for all your types.
//
// Notice that the field names in this struct are NOT in the same order as
// the fields in the CSV data!
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record {
latitude: f64,
longitude: f64,
population: Option<u64>,
city: String,
state: String,
}
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.deserialize() {
let record: Record = result?;
println!("{:?}", record);
// Try this if you don't like each record smushed on one line:
// println!("{:#?}", record);
}
Ok(())
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
コンパイルして実行すると、前と似たような感じの出力を見れる。
$ cargo build
$ ./target/debug/csvtutor < uspop.csv
Record { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
Record { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }
Record { latitude: 33.7133333, longitude: -87.3886111, population: None, city: "Oakman", state: "AL" }
いまいちど強調しておきたいのは、run
関数をまったく変更していないという点である:コードは未だにdeserialize
イテレータを用いてレコード上を舐めているものであり、この節のはじめから変わっていない。このコード例で変更されたのはRecord
型の定義と追加されたextern crate
文だけである。Record
型は型エイリアスの代わりにカスタム構造体になっており、結果として、Serdeはそれをデフォルトではどのようにデシリアライズすればいいのか分からない。しかしながら、serde_derive
と呼ばれる特別なコンパイラプラグインを使うことで、構造体の定義をコンパイル時に読み込んで、CSVレコードをRecord
値にデシリアライズ可能なコードを生成する。自動導出を外したときに何が起こるのか見たいなら#[derive(Debug, Deserialize)]
を#[derive(Debug)]
に変更するとよい。
この例でもう一つ言及しておくと良さそうなのは、#[serde(rename_all = "PascalCase"]
の使い方である。これはSerdeが構造体のフィールドをCSVデータ中のヘッダの名前に紐づけるのに役立つ。ヘッダレコードを思い出してもらうと、次のようになっていたと思う:
City,State,Population,Latitude,Longitude
それぞれの名前がキャピタライズされており、しかし私たちの構造体はそうなっていないことに気づかれたはずである。#[serde(rename_all = "PascalCase"]
ディレクティブは、それぞれのフィールドをPascalCase
として解釈することによりこれを修正する。フィールドの最初の一文字が大文字になっているということである。もしこのようにしてSerdeに名前の再配置の仕方を伝えてやらないと、プログラムはエラーとともに終了する:
$ ./target/debug/csvtutor < uspop.csv
CSV deserialize error: record 1 (line: 2, byte: 41): missing field `latitude`
これについては#[serde(rename_all = "PascalCase")]
を使わずとも修正できる。例えばフィールド名をすべて大文字にして、次のように書ける:
#[derive(Debug, Deserialize)]
struct Record {
Latitude: f64,
Longitude: f64,
Population: Option<u64>,
City: String,
State: String,
}
ただしこれはRustの命名規則に違反する(実際に、Rustコンパイラは規約を守らない名前に対して警告を行う)。
別の修正方法は、個別のフィールドについてリネームの仕方をSerdeに教えてやることである。これはフィールドからヘッダ名への一貫した名前の対応規則がないときに重宝する。
#[derive(Debug, Deserialize)]
struct Record {
#[serde(rename = "Latitude")]
latitude: f64,
#[serde(rename = "Longitude")]
longitude: f64,
#[serde(rename = "Population")]
population: Option<u64>,
#[serde(rename = "City")]
city: String,
#[serde(rename = "State")]
state: String,
}
フィールドのリネームや、他のSerdeディレクティブについてより深く知りたい場合は、Serdeドキュメントのattributesの項
を読んでもらいたい。
Serdeで正しくないデータを処理する
この節では、きれいでないデータを扱う方法を簡潔な例で示す。練習のために、本節では、今まで使っていたUS人口データを少しばかりおかしくしたデータを利用する。このデータは今まで使っていたデータより少しだけ乱雑になっている。データは次のコマンドで入手できる:
$ curl -LO 'https://raw.githubusercontent.com/BurntSushi/rust-csv/master/examples/data/uspop-null.csv'
前節のプログラムを実行するところから再開しよう。
[以下のコードは前節から変わっていない]
//tutorial-read-serde-invalid-01.rs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record {
latitude: f64,
longitude: f64,
population: Option<u64>,
city: String,
state: String,
}
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.deserialize() {
let record: Record = result?;
println!("{:?}", record);
}
Ok(())
}
コンパイルし乱雑化したデータを食わせてみる。
$ cargo build
$ ./target/debug/csvtutor < uspop-null.csv
Record { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
Record { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }
Record { latitude: 33.7133333, longitude: -87.3886111, population: None, city: "Oakman", state: "AL" }
# ... more records
CSV deserialize error: record 42 (line: 43, byte: 1710): field 2: invalid digit found in string
なにが起こったのだろうか? プログラムは数十個のレコードを表示し、それからデシリアライズの問題に蹴つまずいて停止してしまった。エラーメッセージは、43行目の添え字2番目のフィールド(Population
フィールド)が正しくない数字であることを伝えている。43行目のデータはどのようになっているのか?
$ head -n 43 uspop-null.csv | tail -n1
Flint Springs,KY,NULL,37.3433333,-86.7136111
3番目のフィールド(添え字2)には人口数か空データが入っていることが期待されている。しかるに、このデータではNULL
という値らしきものが入っているようであり、これがおそらくデータの数え上げが不可であることを示しているようである。
現行のプログラムの問題は、NULL
文字列をOption<u64>
にデシリアライズする方法が分からないために、レコードの読み込みが失敗するということである。すなわち、Option<u64>
が空のフィールドまたは整数にしか対応していない[ために起こる問題である]。
これを修正するために、SerdeはどんなデシリアライズのエラーもNone
値に変換する方法を提供している。
//tutorial-read-serde-invalid-02.rs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record {
latitude: f64,
longitude: f64,
#[serde(deserialize_with = "csv::invalid_option")]
population: Option<u64>,
city: String,
state: String,
}
fn run() -> Result<(), Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
for result in rdr.deserialize() {
let record: Record = result?;
println!("{:?}", record);
}
Ok(())
}
コンパイルし実行すると、他の例のごとくファイルの終わりまで実行できるようになる。
$ cargo build
$ ./target/debug/csvtutor < uspop-null.csv
Record { latitude: 65.2419444, longitude: -165.2716667, population: None, city: "Davidsons Landing", state: "AK" }
Record { latitude: 60.5544444, longitude: -151.2583333, population: Some(7610), city: "Kenai", state: "AK" }
Record { latitude: 33.7133333, longitude: -87.3886111, population: None, city: "Oakman", state: "AL" }
# ... and more
この例で変更されたのは、Record
型のpopulation
フィールドに、次のattributeを追加した点だけである。
#[serde(deserialize_with = "csv::invalid_option")]
invalid_option
関数はごく単純なことを行うジェネリックなヘルパー関数である:この関数がOption
フィールドに適用されると、すべてのデシリアライゼーションのエラーをNone
値に変換する。これは乱雑なCSVデータを扱う必要にかられた場合便利である。
CSVの書き込み
この節では、CSVデータを書き込むいくつかの例を示す。CSVデータの書き込みは出力形式を制御できるので読み込みより簡単である。
基本的な例から始めてみよう:いくつかのCSVレコードをstdout
に書き出すコードである。
//tutorial-write-01.rs
extern crate csv;
use std::error::Error;
use std::io;
use std::process;
fn run() -> Result<(), Box<Error>> {
let mut wtr = csv::Writer::from_writer(io::stdout());
// Since we're writing records manually, we must explicitly write our
// header record. A header record is written the same way that other
// records are written.
wtr.write_record(&["City", "State", "Population", "Latitude", "Longitude"])?;
wtr.write_record(&["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])?;
wtr.write_record(&["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])?;
wtr.write_record(&["Oakman", "AL", "", "33.7133333", "-87.3886111"])?;
// A CSV writer maintains an internal buffer, so it's important
// to flush the buffer when you're done.
wtr.flush()?;
Ok(())
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
コンパイルし実行すると、CSVデータが表示される。
$ cargo build
$ ./target/debug/csvtutor
City,State,Population,Latitude,Longitude
Davidsons Landing,AK,,65.2419444,-165.2716667
Kenai,AK,7610,60.5544444,-151.2583333
Oakman,AL,,33.7133333,-87.3886111
先に進む前に、write_record
メソッドを詳しく調べてみるとよいだろう。上の例だけだとかなり単純に見えるが、Rustの初心者にとっては型シグネチャが少しばかり仰々しいものに見えることだろう:
pub fn write_record<I, T>(&mut self, record: I) -> csv::Result<()>
where I: IntoIterator<Item=T>, T: AsRef<[u8]>
{
// 実装は省略
}
この型シグネチャを理解するためには、一つ一つ分解して見ていく必要がある:
- このメソッドは2つのパラメータを取る:
self
とrecord
だ。 -
self
は特別なパラメータでWriter
それ自身に対応する。 -
record
は書き込みを行いたいCSVレコードである。ジェネリックな型I
を持つ。 - このメソッドの
where
節では、型I
がIntoIterator<Item=T>
境界により制限されている。これの意味するところは、I
はIntoIterator
トレイトを満足させる実装を持っている必要があるということである。IntoIterator
traitのドキュメントを見ると、イテレータを構築できるような型について記述されているのがわかる。この例では、我々はI
とは別のジェネリックな型T
の値を生じるイテレータを欲している。ここでT
は書き込みを行いたいそれぞれのフィールドの型を表している。 -
T
はwhere
節でまた現れており、AsRef<[u8]>
境界という制約を受けている。AsRef
トレイトはRustにおいて型同士のゼロコスト(zero-cost)な変換を記述する方法である。この例では、AsRef<[u8]>
における[u8]
はT
からバイト列のスライスをborrowすることができるということを意味する。AsRef<[u8]>
境界はString
,&str
,Vec<u8>
のような型がすべて条件を満たすため有用である。 - 最後に、このメソッドは型
csv::Result<()>
の値を返す。これはResult<(), csv::Error>
の略記である。これはwrite_record
が成功した暁にはなにも返さず、失敗した時はcsv::Error
を返すことを意味する。
さて、ここで学んだwrite_record
の型シグネチャの知識を応用に移してみよう。前の例を思い出すと、次のように関数を使っていた:
wtr.write_record(&["field 1", "field 2", "etc"])?;
この呼び出しはどのように型の一致を確かめているのか? まず、このコードにおけるそれぞれのフィールドの型は&'static str
である(Rustにおいては単なる文字列リテラル)。これをスライスリテラルにはめ込むと、パラメータの型は&'static [&'static str]
となり、lifetime注釈を省いて簡潔にすると&[&str]
となる。スライスはIntoIterator
境界を満足し、文字列はAsRef<[u8]>
境界を満足するので、結局この関数呼び出しは合法に行われる。
以下にwrite_record
を呼び出すいくつかの方法を示す:
// A slice of byte strings.
wtr.write_record(&[b"a", b"b", b"c"]);
// A vector.
wtr.write_record(vec!["a", "b", "c"]);
// A string record.
wtr.write_record(&csv::StringRecord::from(vec!["a", "b", "c"]));
// A byte record.
wtr.write_record(&csv::ByteRecord::from(vec!["a", "b", "c"]));
締めくくりに、本節で最初に示したコード例が、stdout
に出力する代わりに、簡単にファイル書き出しに変更できることを示して終わろう。
//tutorial-write-02.rs
extern crate csv;
use std::env;
use std::error::Error;
use std::ffi::OsString;
use std::process;
fn run() -> Result<(), Box<Error>> {
let file_path = get_first_arg()?;
let mut wtr = csv::Writer::from_path(file_path)?;
wtr.write_record(&["City", "State", "Population", "Latitude", "Longitude"])?;
wtr.write_record(&["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])?;
wtr.write_record(&["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])?;
wtr.write_record(&["Oakman", "AL", "", "33.7133333", "-87.3886111"])?;
wtr.flush()?;
Ok(())
}
/// Returns the first positional argument sent to this process. If there are no
/// positional arguments, then this returns an error.
fn get_first_arg() -> Result<OsString, Box<Error>> {
match env::args_os().nth(1) {
None => Err(From::from("expected 1 argument, but got none")),
Some(file_path) => Ok(file_path),
}
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
タブ区切りされた値を書き出す
前節では、単純なCSVデータをstdout
に書き出す方法を見てきた:
City,State,Population,Latitude,Longitude
Davidsons Landing,AK,,65.2419444,-165.2716667
Kenai,AK,7610,60.5544444,-151.2583333
Oakman,AL,,33.7133333,-87.3886111
ここであなたは自問自答するかもしれない。書き出すデータがかくも単純であるならば、なぜCSVライターが必要になるのか? さて、CSVライターを使う利点は、データの完全性(integrity)を損なうことなくあらゆるデータ型を取り扱うことができるということである。すなわち、CSVライターは、いつデータの中に現れるリテラルクォートをエスケープするか、いつ特殊なCSV文字を含むフィールドをクォートするかについて知っている。またCSVライターは、異なるデリミタやクォート戦略を設定するのにも使える。
この節では、CSVライターの設定をいじる方法をちらっとだけ見ていく。特にここでは、CSVの代わりにTSV("tab separeted value")を書き込み、またCSVライターに頼んで非数値のフィールドをクォートしてもらう、ということをする。以下が例である:
//tutorial-write-delimiter-01.rs
fn run() -> Result<(), Box<Error>> {
let mut wtr = csv::WriterBuilder::new()
.delimiter(b'\t')
.quote_style(csv::QuoteStyle::NonNumeric)
.from_writer(io::stdout());
wtr.write_record(&["City", "State", "Population", "Latitude", "Longitude"])?;
wtr.write_record(&["Davidsons Landing", "AK", "", "65.2419444", "-165.2716667"])?;
wtr.write_record(&["Kenai", "AK", "7610", "60.5544444", "-151.2583333"])?;
wtr.write_record(&["Oakman", "AL", "", "33.7133333", "-87.3886111"])?;
wtr.flush()?;
Ok(())
}
コンパイルし実行すると次の出力を得る:
$ cargo build
$ ./target/debug/csvtutor
"City" "State" "Population" "Latitude" "Longitude"
"Davidsons Landing" "AK" "" 65.2419444 -165.2716667
"Kenai" "AK" 7610 60.5544444 -151.2583333
"Oakman" "AL" "" 33.7133333 -87.3886111
この例では、新しい型QuoteStyle
を用いている。QuoteStyle
型は異なるクォート戦略を表現しており使えるようにしている。デフォルトでは必要になったときしかフィールドにクォートを加えない。これはおそらく大多数のユースケースでうまくいくが、フィールドの周りにクォートを付けるようCSVライターに頼むこともできるし、決してクォートを付けないようにすることや、非数値のフィールドに付けるように頼むこともできる。
Serdeとともに書き込む
CSVリーダーがSerdeによりRustの型への自動デシリアライゼーションをサポートしていたように、CSVライターもSerdeを使ってRustの型への自動シリアライゼーションをサポートしている。本節では、これの使い方を学ぶ。
読み込みと同様に、Rustのタプルをシリアライズする方法から見ていこう:
//tutorial-write-serde-01.rs
fn run() -> Result<(), Box<Error>> {
let mut wtr = csv::Writer::from_writer(io::stdout());
// まだheaderを手動で書く必要がある
wtr.write_record(&["City", "State", "Population", "Latitude", "Longitude"])?;
// しかし`serialize`で基本的なRustの値をレコードに書き込むことができる
//
// 留意すべきは奇妙な文法`None::<u64>`が必要とされている点である
// これは`None`自身は具体的な型を持たないのだが、Serdeが
// シリアライズを行うために具体型を必要としているのである。
// すなわち、`None`は型`None::<u64>`を持ちそれは`Option<u64>`という型を持つ。
wtr.serialize(("Davidsons Landing", "AK", None::<u64>, 65.2419444, -165.2716667))?;
wtr.serialize(("Kenai", "AK", Some(7610), 60.5544444, -151.2583333))?;
wtr.serialize(("Oakman", "AL", None::<u64>, 33.7133333, -87.3886111))?;
wtr.flush()?;
Ok(())
}
コンパイルし実行すると期待した出力が得られる:
$ cargo build
$ ./target/debug/csvtutor
City,State,Population,Latitude,Longitude
Davidsons Landing,AK,,65.2419444,-165.2716667
Kenai,AK,7610,60.5544444,-151.2583333
Oakman,AL,,33.7133333,-87.3886111
ここで重要なのは、データを書き込むのにwrite_record
の代わりにserialize
を用いている点である。特に、write_record
は文字列的なデータを含む、単純なレコードの書き出しに利用が限られる。いっぽう、serialize
はデータが数値、浮動小数点数、オプション値のようにより複雑な値から成る場合に使われる。もちろん、手書きで複雑な値を文字列に変換してからwrite_record
することも可能であるが、Serdeはそうした作業を自動化してくれるものである。
ここまでで見てきたように、カスタム構造体もまたCSVレコードとしてシリアライズすることができる。おまけに、構造体中のフィールドは自動的にヘッダレコードとして書き出されるのだ!
CSVレコードとしてのカスタム構造体を書くためには、[serdeで読み込みを行なったときと]同様にserde_derive
crateを使う必要がある。(もしまだ書いていないなら)Cargo.toml
の[dependencies]
に必要な依存関係を加えよう。
serde = "1"
serde_derive = "1"
またコードにextern crate
文を加える必要がある。以下に例を示す:
//tutorial-write-serde-02.rs
extern crate csv;
extern crate serde;
#[macro_use]
extern crate serde_derive;
use std::error::Error;
use std::io;
use std::process;
// 構造体は`Serialize`と`Deserialize`双方からderiveできる
#[derive(Debug, Serialize)]
#[serde(rename_all = "PascalCase")]
struct Record<'a> {
city: &'a str,
state: &'a str,
population: Option<u64>,
latitude: f64,
longitude: f64,
}
fn run() -> Result<(), Box<Error>> {
let mut wtr = csv::Writer::from_writer(io::stdout());
wtr.serialize(Record {
city: "Davidsons Landing",
state: "AK",
population: None,
latitude: 65.2419444,
longitude: -165.2716667,
})?;
wtr.serialize(Record {
city: "Kenai",
state: "AK",
population: Some(7610),
latitude: 60.5544444,
longitude: -151.2583333,
})?;
wtr.serialize(Record {
city: "Oakman",
state: "AL",
population: None,
latitude: 33.7133333,
longitude: -87.3886111,
})?;
wtr.flush()?;
Ok(())
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
ヘッダレコードを明示的に書いていないことに注目して欲しい。コンパイルし実行すると、前回の出力と同じものが得られる。
$ cargo build
$ ./target/debug/csvtutor
City,State,Population,Latitude,Longitude
Davidsons Landing,AK,,65.2419444,-165.2716667
Kenai,AK,7610,60.5544444,-151.2583333
Oakman,AL,,33.7133333,-87.3886111
このケースでは、serialize
メソッドはフィールド名付きの構造体を書き出していることを知らされている。このようにすると、serialize
は構造体のフィールド定義順に、自動的にヘッダレコードを書き出す(他のレコードがすでに書き出されていない場合に限り)。ちなみにこの挙動はWriteBuilder::has_headers
メソッドから無効化することができる。
ついでにRecord
構造体のlifetimeパラメータに言及しておく。
struct Record<'a> {
city: &'a str,
state: &'a str,
population: Option<u64>,
latitude: f64,
longitude: f64,
}
頭の'a
lifetimeパラメータはcity
とstate
の文字列スライスのlifetimeに対応している。これはRecord
構造体がborrowされたデータを含むことを述べている。この構造体をなんのデータもborrowしないように、つまりlifetimeなしで書くこともできる。
struct Record {
city: String,
state: String,
population: Option<u64>,
latitude: f64,
longitude: f64,
}
しかしながら、borrowされた&str
をownedなString
型で置き換えるということは、レコードを書き込むたびにcity
とstate
双方の新しいString
をアロケートしなければならないことを意味する。これでも書き込みはできるにはできるのだが、メモリとパフォーマンスを少しばかり無駄遣いしているだろう。
シリアライゼーションの規則について詳しく知りたい向きは、Writer::serialize
を参照してほしい。
パイプライニング
本節では、CSVデータを入力として受け取り、加工またはフィルタしたCSVを出力するプログラムをいくつか例示する。本節を読めば、読者はCSVデータを効率的に読み書きする方法を会得することだろう。Rustはこのような課題を行う上で優位な立場にある。ゆえに高レベルなCSVライブラリの利便性とともにパフォーマンスの恩恵を得ることができるだろう。
検索によるフィルタ
最初に検討するCSVパイプライニングの例は、単純なフィルタである。これはstdinから入力されたなんらかのCSVデータと、単一の文字列クエリを固定引数として受けとり、クエリを含むフィールドがあった列をCSVデータとして出力する。
//tutorial-pipeline-search-01.rs
extern crate csv;
use std::env;
use std::error::Error;
use std::io;
use std::process;
fn run() -> Result<(), Box<Error>> {
// クエリを固定引数として受け取る
// 引数が与えられなかった場合はエラーを返す
let query = match env::args().nth(1) {
None => return Err(From::from("expected 1 argument, but got none")),
Some(query) => query,
};
// CSVリーダー(stdin)とCSVライター(stdout)を構築する
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut wtr = csv::Writer::from_writer(io::stdout());
// データレコードを読み出す前にヘッダレコードを書き出す
wtr.write_record(rdr.headers()?)?;
// `rdr`上のレコードをすべて舐め、`query`を含むレコードを`wtr`に書き込む
for result in rdr.records() {
let record = result?;
if record.iter().any(|field| field == &query) {
wtr.write_record(&record)?;
}
}
// CSVライターは内部的にバッファを用いている
// よって処理の終わりで常にflushを行う必要がある
wtr.flush()?;
Ok(())
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
コンパイルし、uspop.csv
を標準入力としてMA
というクエリとともに実行すると、1つのレコードが照合されることがわかる。
$ cargo build
$ ./csvtutor MA < uspop.csv
City,State,Population,Latitude,Longitude
Reading,MA,23441,42.5255556,-71.0958333
以上の例は、実際のところ何も新しいことはしていない。単に以前の節で学んだCSVリーダーとCSVライターの使い方を組み合わせただけだ。
この例にもうひと工夫加えてみよう。現実の世界では、しばしばエンコードが正しくないCSVデータと戦うはめになることがある。一例として、あなたが出くわす可能性があるのは、Latin-1でエンコードされたCSVデータである。残念ながら、これまで見てきた例がそうであったように、我々のCSVリーダーはデータがすべてUTF-8でエンコードされていることを前提としている。今取り組んでいるデータがすべてASCII文字列であれば(それはLatin-1とUTF-8双方のサブセットなので)いかなる問題も起こらない。しかしUTF-8で無効なLatin-1エンコード文字をわずかに混ぜ込んだuspop.csv
を使ってみると、事情の違いが見えてくる。そのようなデータを以下から手に入れてみよう:
$ curl -LO 'https://raw.githubusercontent.com/BurntSushi/rust-csv/master/examples/data/uspop-latin1.csv'
この新しいデータに対し、前のコマンドを実行すると何が起こるか見てみよう。
$ ./csvtutor MA < uspop-latin1.csv
City,State,Population,Latitude,Longitude
CSV parse error: record 3 (line 4, field: 0, byte: 125): invalid utf-8: invalid UTF-8 in field 0 near byte index 0
エラーメッセージは何が間違っていたのかを正しく伝えている。データの4行目を見てみよう:
$ head -n4 uspop-latin1.csv | tail -n1
Õakman,AL,,33.7133333,-87.3886111
このケースでは、一番最初の文字がLatin-1のÕとなっている:これは2バイト文字0xD5
としてエンコードされており、UTF-8では正しい文字とならない。さて、こんな風にCSVパーサがデータを喉に詰まらせて窒息した場合、どうしたらよいだろうか? 解決には2つの選択肢がある。最初の選択肢はCSVデータそのものを正しいUTF-8文字列に修正することである。iconv
のようなツールが手伝ってくれるのもあって、エンコード変換はなんだかんだで良案の部類に入るかもしれない。しかしデータの修正ができない、もしくはしたくない場合、CSVの読み込み側でなるたけエンコードに依存しない方法を取ることができる(ASCIIが依然としてそのエンコードの有効なサブセットであることが必要)。このトリックは文字列レコードの代わりにバイトレコードを使うことによりなされる。
ここまでにおいて、このライブラリにおけるレコードの型について十分に説明してこなかった。本節はその導入にふさわしい頃合いだと思う。レコードにはStringRecord
とByteRecord
の2つがある。それぞれCSVデータの中の一つのレコードを表しており、レコードは任意長のフィールドの列からなる。StringRecord
とByteRecord
の唯一違うところは、StringRecord
は正しいUTF-8であることが保証されており、ByteRecord
は任意のバイト列を含むということである。誤解のないように述べておくと、メモリ内部における両者の型の表現は同一である。
上記の知識で身を固めると、前の例でUTF-8でないデータに対してプログラムを走らせたとき、なぜエラーが出たのか理解できるようになる。すなわち、record
を呼び出した時に、StringRecord
のイテレータが返却される。StringRecord
はUTF-8であることが保証されるために、正しくないUTF-8に対してStringRecord
を組み立てようという試みは、我々が見たエラーという結果に終わった、ということである。
上記のコード例を動かすのに必要なことはStringRecord
をByteRecord
に変更すること、それだけである。このためにrecords
の代わりにbyte_records
を用いてイテレータを作成する。またヘッダについても正しくないUTF-8を含むと思われる場合headers
ではなくbyte_headers
を使う。以下に変更を示す。
//tutorial-pipeline-search-02.rs
fn run() -> Result<(), Box<Error>> {
let query = match env::args().nth(1) {
None => return Err(From::from("expected 1 argument, but got none")),
Some(query) => query,
};
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut wtr = csv::Writer::from_writer(io::stdout());
wtr.write_record(rdr.byte_headers()?)?;
for result in rdr.byte_records() {
let record = result?;
// `query`は`String`であり`field`は今`&[u8]`となった
// `query`を比較できるよう`&[u8]`に変換する必要がある
if record.iter().any(|field| field == query.as_bytes()) {
wtr.write_record(&record)?;
}
}
wtr.flush()?;
Ok(())
}
コンパイルして実行すると、本節最初のコードと同様の結果が得られる。これで正しくないUTF-8に対しても動作するプログラムが得られた。
$ cargo build
$ ./csvtutor MA < uspop-latin1.csv
City,State,Population,Latitude,Longitude
Reading,MA,23441,42.5255556,-71.0958333
人口によりフィルタする
本節では、CSVデータを読み書きするもう一つの例を示す。以下で取り上げるのは、任意のレコードを取り扱う代わりに、Serdeを用いて特定の型のレコードをシリアライズ・デシリアライズするものである。
ここでは、データを人口数でフィルタリングするプログラムを書きたいと考えている。具体的には、どのレコードが特定の人口数のしきい値を満たしているのかを確認したいと考えている。そのためには、単純に不等式で比較してフィルタするだけでは足りず、人口数が欠損しているレコードも考慮する必要がある。このような場面ではOption<T>
が重宝する。コンパイラが、人口数の欠損したデータが現れた場合にそれを教えてくれるからである。
この例ではSerdeを使うので、Cargo.toml
の[dependencies]
に依存関係を記述するのを忘れないように。
serde = "1"
serde_derive = "1"
コードは以下:
//tutorial-pipeline-pop-01.rs
extern crate csv;
extern crate serde;
#[macro_use]
extern crate serde_derive;
use std::env;
use std::error::Error;
use std::io;
use std::process;
// 前回の例と違い、デシリアライズとシリアライズ両方をderiveする
// これは型から自動的にデシリアライズとシリアライズを行えるということである
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "PascalCase")]
struct Record {
city: String,
state: String,
population: Option<u64>,
latitude: f64,
longitude: f64,
}
fn run() -> Result<(), Box<Error>> {
// クエリとなる固定引数を受け取る
// もし引数が与えられないか整数でない場合はエラーを返す
let minimum_pop: u64 = match env::args().nth(1) {
None => return Err(From::from("expected 1 argument, but got none")),
Some(arg) => arg.parse()?,
};
// CSVリーダーとCSVライターをstdinとstdoutについてそれぞれ構成する
// 注意すべきはヘッダを明示的に書き込む必要がないという点である
// カスタム構造体をシリアライズしているので、ヘッダの書き込みは自動的になされる
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut wtr = csv::Writer::from_writer(io::stdout());
// `rdr`から入力されたレコード上をイテレートし、`minimum_pop`以上の
// 人口数を含むレコードをwriteする
for result in rdr.deserialize() {
// Serdeからデシリアライズするときは、どの型にレコードを落とし込みたいのかを示す
// 型ヒントを必要とすることを記憶に留めておいて欲しい
let record: Record = result?;
// `map_or`は`Option`型上のコンビネータである。
// 2つのパラメータを取る。1つは`Option`の値が`None`であるときに返す値
// (例:レコードの人口数が欠損していたとき)
// 2つ目は`Option`の値が`Some`であったときに同じ型の別の値を返すクロージャである
// この例では、コマンドラインから得た下限の人口数に対してテストを行なっている
if record.population.map_or(false, |pop| pop >= minimum_pop) {
wtr.serialize(record)?;
}
}
// CSVライターは内部バッファを利用しているのでflushを行う必要がある
wtr.flush()?;
Ok(())
}
fn main() {
if let Err(err) = run() {
println!("{}", err);
process::exit(1);
}
}
コンパイルしてしきい値100000を与えて実行すると、3つのレコードが該当することがわかる。あとヘッダーは明示的にwriteしていないが、自動的に加えられていることに気づいただろう。
$ cargo build
$ ./target/debug/csvtutor 100000 < uspop.csv
City,State,Population,Latitude,Longitude
Fontana,CA,169160,34.0922222,-117.4341667
Bridgeport,CT,139090,41.1669444,-73.2052778
Indianapolis,IN,773283,39.7683333,-86.1580556
パフォーマンス
本節では、CSVリーダーからその力をひとしずく残らず絞り出す方法を見ていく。実は、これまで使ってきたAPIのほとんどは、高レベルの簡便性を念頭に設計されており、それに伴うオーバーヘッドがあった。ほとんどの場合、このようなコストは不要なアロケーション周りを解決することによりうまくいく。よって、本節の大部分は、可能な限りアロケーションを抑つついかにしてCSVのパースを行うか、ということを示すことに当てられる。
本題に入る前に、Rustのパフォーマンスを語る上で抑えておくべき重要な前提条件が2つあるのでそれについて話す。
第一に、パフォーマンスに関心があるときは、単にcargo build
するのではなくcargo build --release
でコードをコンパイルする必要がある。--release
フラグによる指示は、コンパイラにコード最適化のためにより多くの時間を取らせる。--release
フラグ付きでコンパイルされたプログラムはtarget/release/csvtutor
にある。このチュートリアルを通して、我々はcargo build
のみを使ってきたが、これは今まで扱ってきたデータが小さく、速度に焦点を当てていなかったからである。cargo build --release
の短所はコンパイル時間が長くかかることである。
第二に、我々はチュートリアルを通して、100レコードしか持たない小さなデータセットを使ってきた点を指摘しておきたい。--release
フラグ抜きにしてコンパイルしたプログラムでも、100レコードを処理する程度で速度が問題になるようにするのは逆に難しい。それゆえに、実際にパフォーマンスの問題に相対するために、より大きなデータセットが必要である。そのようなデータセットを手に入れるために、uspop.csv
の元になったオリジナルのデータをダウンロードしよう。 注意:以下のデータのダウンロードサイズは41MBの圧縮ファイルで、解凍すると145MBになる。
$ curl -LO http://burntsushi.net/stuff/worldcitiespop.csv.gz
$ gunzip worldcitiespop.csv.gz
$ wc worldcitiespop.csv
3173959 5681543 151492068 worldcitiespop.csv
$ md5sum worldcitiespop.csv
6198bd180b6d6586626ecbf044c1cca5 worldcitiespop.csv
最後に、本節は厳密なベンチマークをしているわけではないことを断っておく。厳密な分析からは少し外れ、やや実経過時間と直感に依存する形で分析を行う。
アロケーションを償却する
パフォーマンスを計測するためには、そもそも何を測っているのかということに注意する必要がある。改善しようとしているコードを測定する際は、その挙動を変更しないように注意するべきである。ここで取り上げたい事例は、マサチューセッツの都市人口数に対応するレコードの数え上げに、どの程度時間がかかるかを測定することである。これを実現するためのコード量は非常に少なく、しかし全てのレコードを走査する必要がある。よって、これはCSVのパースにどれくらいかかるかを測定するのに、まずまずの課題といえるだろう。
最適化を行う前に、基点となるプログラム例から始めよう。worldcitiespop.csv
からマサチューセッツの都市(MA
)に該当するレコード数を数える処理である。
//tutorial-perf-alloc-01.rs
extern crate csv;
use std::error::Error;
use std::io;
use std::process;
fn run() -> Result<u64, Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut count = 0;
for result in rdr.records() {
let record = result?;
if &record[0] == "us" && &record[3] == "MA" {
count += 1;
}
}
Ok(count)
}
fn main() {
match run() {
Ok(count) => {
println!("{}", count);
}
Err(err) => {
println!("{}", err);
process::exit(1);
}
}
}
コンパイルし実行してどの程度の時間がかかるか見てみよう。--release
フラグを忘れないように(一度--release
フラグなしでどれくらい時間がかかるか見てみるのもいいだろう)。
$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m0.645s
user 0m0.627s
sys 0m0.017s
よろしい、ではこのプログラムをより速くするために最初にできることは何か? 本節ではタイトルの通りアロケーションを償却することにより高速化を図るのだが、その前にできる簡単な最適化がある:StringRecord
の代わりにByteRecord
でイテレーションすることである。前節を思い返して欲しいのだが、StringRecord
は正しいUTF-8であることを保証するので、文字列の内容が本当にUTF-8であるかバリデーションする処理が入る(もしバリデーションが失敗したら、CSVリーダーはエラーを返す)。次の例で示すように、バリデーションを外すだけで速度を引き上げることができることが分かるだろう。
//tutorial-perf-alloc-02.rs
fn run() -> Result<u64, Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut count = 0;
for result in rdr.byte_records() {
let record = result?;
if &record[0] == b"us" && &record[3] == b"MA" {
count += 1;
}
}
Ok(count)
}
コンパイルして実行する:
$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m0.429s
user 0m0.403s
sys 0m0.023s
UTF-8バリデーションを外すだけで30%高速化された。しかしながら、本当にUTF-8バリデーションを除去しても問題ないのだろうか? なにか失ったものはないのか? この例では、UTF-8バリデーションを外してByteRecord
を使って完全に問題ない。なぜならレコード中の2つのフィールドに対して生byteで比較を行なっているだけだからである。
if &record[0] == b"us" && &record[3] == b"MA" {
count += 1;
}
特に、生のbyteそれ自体で等価チェックを行うときは、レコードが正しいUTF-8であるかどうかは関係ない。
ByteRecord
が&[u8]
でフィールドへのアクセスを行わせるのに対して、StringRecord
によるUTF-8バリデーションは&str
型からのフィールドへのアクセスを提供しているので便利である。&str
はRustにおいてborrowされた文字列の型であり、部分文字列検索のような利便性の高い文字列APIを提供している。よって、まずStringRecord
を使うというのはよい習慣である。しかしより速度を求め、任意のバイト列を扱いたい場合に、コードをByteRecord
に変更することはよいアイデアである。
先に進もう。アロケーション償却により速度を引き上げることを試みよう。アロケーション償却はアロケーションを一度だけ(あるいはごく稀に)行い、追加のアロケーションが必要になりそうなときに、すでに割り当てたものの使い回しを企てるテクニックである。前の例ではCSVリーダ上のrecord
またはbyte_record
によってイテレータを利用していた。これらのイテレータは、それが産生(yield)するすべてのレコードに対して新しいメモリ割り当てを行ない、つまり次々に対応するアロケーションを行なっていた。これはイテレータがイテレータ自体からborrowしている要素を産生することができないため、新しいアロケーションを行う方が簡便だからである。
もしイテレータの利用を控えめにしたいと望むなら、単一のByteRecord
を用いてアロケーションを償却し、CSVリーダーに読み込みを行うよう頼むことができる。これはReader::read_byte_record
によって実現できる:
//tutorial-perf-alloc-03.rs
fn run() -> Result<u64, Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut record = csv::ByteRecord::new();
let mut count = 0;
while rdr.read_byte_record(&mut record)? {
if &record[0] == b"us" && &record[3] == b"MA" {
count += 1;
}
}
Ok(count)
}
コンパイルし実行する:
$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m0.308s
user 0m0.283s
sys 0m0.023s
やったね。これは前の例よりさらに30%速くなっている。一番最初のコードから比べると50%の向上だ。
read_byte_record
の型シグネチャを調べることによりコードを解剖してみよう。
fn read_byte_record(&mut self, record: &mut ByteRecord) -> csv::Result<bool>;
このメソッドはCSVリーダーを第1引数(self
)として取り、第2引数としてByteRecord
の可変なborrowを取っている。返値の型はcsv::Result<bool>
である(これはcsv::Result<bool, csv::Error>
と同じ)。返り値はレコードが読み込まれたときに限りtrue
となる。返り値がfalse
であったときは、CSVリーダーからの入力が枯渇したことを意味する。このメソッドは、次のレコードの内容を、与えられたByteRecord
にコピーする動作をする。すべてのレコードを読み込むのに同じByteRecord
を利用するため、データのために前もって割り当てられた場所がある。read_byte_record
が走った時、そこにあった内容を新しいレコードで上書きする。それはすでにアロケートされた空間を再利用することができることを意味する。結果として、これは償却されたアロケーションとなる。
練習でコードを書くときは、ByteRecord
の代わりにStringRecord
を使い、read_byte_record
の代わりにReader::read_record
を使うことも一考に値するだろう。これはUTF-8バリデーションのコストと引き換えに、簡便なRustの文字列へのアクセスを可能にし、しかしそれぞれの新しいStringRecord
に対して新しいアロケーションを行わずに済むという利点がある。
Serdeとゼロ・アロケーション
本節では、Serdeの使い方と高速化について簡潔に検討を行う。最適化のカギとなるのは、すでに予想がついているかもしれないが、アロケーションの償却である。
前節と同様に、Serdeを利用して、最適化を施していない基点となるプログラム例から始めよう:
//tutorial-perf-serde-01.rs
extern crate csv;
extern crate serde;
#[macro_use]
extern crate serde_derive;
use std::error::Error;
use std::io;
use std::process;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record {
country: String,
city: String,
accent_city: String,
region: String,
population: Option<u64>,
latitude: f64,
longitude: f64,
}
fn run() -> Result<u64, Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut count = 0;
for result in rdr.deserialize() {
let record: Record = result?;
if record.country == "us" && record.region == "MA" {
count += 1;
}
}
Ok(count)
}
fn main() {
match run() {
Ok(count) => {
println!("{}", count);
}
Err(err) => {
println!("{}", err);
process::exit(1);
}
}
}
コンパイルして実行してみる:
$ cargo build --release
$ ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m1.381s
user 0m1.367s
sys 0m0.013s
最初に気づくことは、これは前節のプログラムよりかなり遅いということである。これは、それぞれのレコードのデシリアライズに、一定のオーバーヘッドがかかっていることに起因する。特に、いくつかのフィールドは整数または浮動小数点数としてパースする必要があるが、この処理が安くない。しかし希望はある。ここでも高速化できるからだ。
最初の高速化の試みとして、プログラムをアロケーション償却するものに変更する。Serdeでこれをやるのは少しトリッキーになる。というのも、Record
の型を変更し、素手でデシリアライゼーションAPIに触れる必要があるからだ。以下のコードを見てほしい:
//tutorial-perf-serde-02.rs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record<'a> {
country: &'a str,
city: &'a str,
accent_city: &'a str,
region: &'a str,
population: Option<u64>,
latitude: f64,
longitude: f64,
}
fn run() -> Result<u64, Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut raw_record = csv::StringRecord::new();
let headers = rdr.headers()?.clone();
let mut count = 0;
while rdr.read_record(&mut raw_record)? {
let record: Record = raw_record.deserialize(Some(&headers))?;
if record.country == "us" && record.region == "MA" {
count += 1;
}
}
Ok(count)
}
コンパイルし実行する。
$ cargo build --release
$ ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m1.055s
user 0m1.040s
sys 0m0.013s
これはパフォーマンス上24%のの改善に対応する。これを実現するために、コードに2つの重要な変更を加えた。
最初の変更はString
の代わりに&str
を含むRecord
型へ変更したことである。前の節のコードを思い出すなら、&str
はborrowされた文字列でありString
はownされた文字列である。String
は常に新しいアロケーションを含意するのに対し、borrowされた文字列はすでに存在するアロケーションを指し示している。この場合、&str
はCSVレコードそれ自身からborrowしている。
二つ目の変更点はReader::deserialize
イテレータの使用をやめた点である。そして代わりにレコードをStringRecord
に明示的にデシリアライズし、それからStringRecord::deserialize
を使うことにより単一のレコードをデシリアライズしている。
二つ目の変更はややトリッキーである。それを動かすには、レコード型をStringRecord内部のデータからborrowしなければならないからだ。これはRecord
値はStringRecord
が作られたスコープの外では生存できないことを意味する。それぞれのイテレーションで同一のStringRecord
を上書きしているために(アロケーション償却のためだ)、あるループでのRecord
の値は、次のループのイテレーションが始まる前に消えている必要がある。そしてこれはコンパイラによって間違いなく強制される。
上に加えてもう一つの最適化を行うことができる:UTF-8バリデーションの除去である。一般的に、これは&str
の代わりに&[u8]、そして
StringRecordの代わりに
ByteRecord`を使うことを意味する。
//tutorial-perf-serde-03.rs
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct Record<'a> {
country: &'a [u8],
city: &'a [u8],
accent_city: &'a [u8],
region: &'a [u8],
population: Option<u64>,
latitude: f64,
longitude: f64,
}
fn run() -> Result<u64, Box<Error>> {
let mut rdr = csv::Reader::from_reader(io::stdin());
let mut raw_record = csv::ByteRecord::new();
let headers = rdr.byte_headers()?.clone();
let mut count = 0;
while rdr.read_byte_record(&mut raw_record)? {
let record: Record = raw_record.deserialize(Some(&headers))?;
if record.country == b"us" && record.region == b"MA" {
count += 1;
}
}
Ok(count)
}
コンパイルし実行する。
$ cargo build --release
$ ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m0.873s
user 0m0.850s
sys 0m0.023s
直前の例より17%速度が向上し、最初の例に比べると37%速度が向上した。
まとめると、Serdeの解析は速いほうだが、しかしCSVを解析する方法としては最速ではない。
標準ライブラリ抜きでのCSV解析
本節では、今までよりニッチな事例を取り上げる:CSV解析を標準ライブラリなしで行うのだ。csv
crate自体は標準ライブラリを要求するのだが、その基盤パーサーはcsv-core
crateの一部であり、これは標準ライブラリに依存しない。標準ライブラリを使わないデメリットは、CSV解析がずっと面倒になるということだ。
csv-core
はcsv
と似たような感じで構造化されている。Reader
とWriter
があり、同様に対応するビルダーとしてReaderBuilder
とWriterBuilder
がある。csv-core
にはレコード型やイテレータがない。代わりに、一度に一つのフィールドかレコードどちらかを読み取ることができる。本節では、より単純な一度に一つのフィールドを読み込むやり方に焦点を当てるが、一度に一つのレコードを読み込んだ方が高速である(こちらのほうが関数呼び出しあたりに多くの仕事をこなしているため)。
本節はパフォーマンス節と足並みを揃えて、マサチューセッツのレコード数を数え上げるプログラムをcsv-core
のみを用いて書く。
(留意して欲しいのは、以下のプログラム例は標準ライブラリを使っている。これは単にI/Oに簡便にアクセスするためで、これは標準ライブラリを使わないと難しい)
//tutorial-perf-core-01.rs
extern crate csv_core;
use std::io::{self, Read};
use std::process;
use csv_core::{Reader, ReadFieldResult};
fn run(mut data: &[u8]) -> Option<u64> {
let mut rdr = Reader::new();
// マサチューセッツのレコード数を数える。
let mut count = 0;
// 現在のフィールドのインデックスを指し示す変数。それぞれのレコードを処理する前に0にリセットされる。
let mut fieldidx = 0;
// 米国内のレコードであれば真値となる。
let mut inus = false;
// フィールドデータのためのバッファ。一番大きなフィールドを保持できるよう十分な大きさを確保する。
let mut field = [0; 1024];
loop {
// 次のCSVフィールドをインクリメンタルに読み込もうとする。
let (result, nread, nwrite) = rdr.read_field(data, &mut field);
// nreadは入力から読み込んだbyte数である。
// read_fieldにこのbyte列を渡してはいけない。
data = &data[nread..];
// nwriteは出力バッファ`field`に書き込まれたbyte数である。
// nwriteで指し示された数以降のバッファの内容は不明である
let field = &field[..nwrite];
match result {
// 全てのデータを前もって読み込むので、次のケースは処理する必要がない。
// データをインクリメンタルに読み込む場合は、さらに読み込みを行うためのシグナルとして機能する。
ReadFieldResult::InputEmpty => {}
// 次のケースは1024 bytesより大きなフィールドが見つかったことを意味する。
// この例では単純に失敗するようにした。
ReadFieldResult::OutputFull => {
return None;
}
// このケースはフィールドの読み込みに成功したことを意味する。
// もしフィールドがレコードの最後のフィールドである場合、
// `record_end`がtrueになる
ReadFieldResult::Field { record_end } => {
if fieldidx == 0 && field == b"us" {
inus = true;
} else if inus && fieldidx == 3 && field == b"MA" {
count += 1;
}
if record_end {
fieldidx = 0;
inus = false;
} else {
fieldidx += 1;
}
}
// このケースはCSVリーダーが全ての入力を成功裡に消費したことを意味する。
ReadFieldResult::End => {
break;
}
}
}
Some(count)
}
fn main() {
// 全ての内容を事前に読み込む。
let mut data = vec![];
if let Err(err) = io::stdin().read_to_end(&mut data) {
println!("{}", err);
process::exit(1);
}
match run(&data) {
None => {
println!("error: could not count records, buffer too small");
process::exit(1);
}
Some(count) => {
println!("{}", count);
}
}
}
コンパイルし実行する:
$ cargo build --release
$ time ./target/release/csvtutor < worldcitiespop.csv
2176
real 0m0.572s
user 0m0.513s
sys 0m0.057s
これは以前の例でcsv
のStringRecord
やByteRecord
を用いて読み込みを行なったときより速くない。これは主に、フィールドを一度に一つだけ読み込むためで、レコードを一度に一つ読み込むよりオーバヘッドが大きくなるのである。これを修正するためには、csv_core::Reader
に定義されているReader::read_record
を使うのがよいだろう。
他に指摘しておきたいことは、やはりこのコード例は他のコード例よりかなり長くなっているということである。これはどのフィールドを読み取っているのか、あるいはすでにReaderに食わせたデータはどれくらいかを知るために、より多くの一時変数を必要とするからである。csv_core
crateを使う根本的な理由として以下の2つが挙げられる:
- 標準ライブラリが使えない環境にある
-
csv
ライクなライブラリを自作したいとき、csv-core
を土台として作ることができる
おわりに
これでチュートリアルは終わりです、おめでとう! CSV解析のような基本的な事項について、これほどまでに多くの言葉を積み重ねることができたのは信じがたいことのように思える。筆者は、このガイドがRust初心者のみならず、プログラミング全般の初心者にとっても理解しやすいように書いたつもりである。ここに挙げた多数の例が、読者を正しい方向へ進む指針となることを筆者は望んでいる。
というわけで、以下にさらに理解を進めるためのいくつかのリンクを紹介する:
-
csv
crateのAPIドキュメンテーションにはライブラリの全てが記述されており、またドキュメント自体に多くのコード例がちりばめられている。 -
csv-index
crateはディスク書き込みを楽にするための、インデックス可能なCSVデータのデータ構造を提供している(ただしこれは制作中のライブラリである)。 - xsvコマンドツールはハイパフォーマンスなCSV処理の万能ナイフである。任意のCSVデータに対し、スライス、選択(select)、探索、ソート、結合(join)、連結(concatenate)、インデックス、整形(format)と統計処理といったことができる。とにかく一度試してみてほしい。