【翻訳】RustとCSV解析(csv crateチュートリアル):後編

Dual-licensed under MIT or the UNLICENSE.

Rust and CSV parsing - Andrew Gallant’s Blog

前編はこちら

翻訳のライセンスはMIT LICENSE

パイプライニング

本節では、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が依然としてそのエンコードの有効なサブセットであることが必要)。このトリックは文字列レコードの代わりにバイトレコードを使うことによりなされる。

ここまでにおいて、このライブラリにおけるレコードの型について十分に説明してこなかった。本節はその導入にふさわしい頃合いだと思う。レコードにはStringRecordByteRecordの2つがある。それぞれCSVデータの中の一つのレコードを表しており、レコードは任意長のフィールドの列からなる。StringRecordByteRecordの唯一違うところは、StringRecordは正しいUTF-8であることが保証されており、ByteRecordは任意のバイト列を含むということである。誤解のないように述べておくと、メモリ内部における両者の型の表現は同一である。

上記の知識で身を固めると、前の例でUTF-8でないデータに対してプログラムを走らせたとき、なぜエラーが出たのか理解できるようになる。すなわち、recordを呼び出した時に、StringRecordイテレータが返却される。StringRecordUTF-8であることが保証されるために、正しくないUTF-8に対してStringRecordを組み立てようという試みは、我々が見たエラーという結果に終わった、ということである。

上記のコード例を動かすのに必要なことはStringRecordByteRecordに変更すること、それだけである。このために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された文字列はすでに存在するアロケーションを指し示している。この場合、&strCSVレコードそれ自身から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-corecsvと似たような感じで構造化されている。ReaderWriterがあり、同様に対応するビルダーとしてReaderBuilderWriterBuilderがある。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

これは以前の例でcsvStringRecordByteRecordを用いて読み込みを行なったときより速くない。これは主に、フィールドを一度に一つだけ読み込むためで、レコードを一度に一つ読み込むよりオーバヘッドが大きくなるのである。これを修正するためには、csv_core::Readerに定義されているReader::read_recordを使うのがよいだろう。

他に指摘しておきたいことは、やはりこのコード例は他のコード例よりかなり長くなっているということである。これはどのフィールドを読み取っているのか、あるいはすでにReaderに食わせたデータはどれくらいかを知るために、より多くの一時変数を必要とするからである。csv_core crateを使う根本的な理由として以下の2つが挙げられる:

  1. 標準ライブラリが使えない環境にある
  2. csvライクなライブラリを自作したいとき、csv-coreを土台として作ることができる

おわりに

これでチュートリアルは終わりです、おめでとう! CSV解析のような基本的な事項について、これほどまでに多くの言葉を積み重ねることができたのは信じがたいことのように思える。筆者は、このガイドがRust初心者のみならず、プログラミング全般の初心者にとっても理解しやすいように書いたつもりである。ここに挙げた多数の例が、読者を正しい方向へ進む指針となることを筆者は望んでいる。

というわけで、以下にさらに理解を進めるためのいくつかのリンクを紹介する:

  • csv crateのAPIドキュメンテーションにはライブラリの全てが記述されており、またドキュメント自体に多くのコード例がちりばめられている。
  • csv-index crateはディスク書き込みを楽にするための、インデックス可能なCSVデータのデータ構造を提供している(ただしこれは制作中のライブラリである)。
  • xsvコマンドツールはハイパフォーマンスなCSV処理の万能ナイフである。任意のCSVデータに対し、スライス、選択(select)、探索、ソート、結合(join)、連結(concatenate)、インデックス、整形(format)と統計処理といったことができる。とにかく一度試してみてほしい。