[C++] csvファイル入出力用のiteratorを作ってみた話

  • 8
    Like
  • 0
    Comment
More than 1 year has passed since last update.

この記事は、C++ Advent Calendar 2015 の10日目の記事です。
昨日は、srz-zumixさんの「 [Catch] REQUIRE の式が展開される仕組み」でした。

今日は、csvフォーマットをiteratorで読み書きしてみる話です。以下のように使えるクラスです。
コードはGitHub上においてあります。(csv_iterator.hpp

#include <iostream>
#include <fstream>
#include "csv_iterator.hpp"

int main(){
    //カンマ区切り(csv)でファイルを読みだして、タブ区切りで画面に出力する
    auto oitr = csv::current(std::cout, csv::mode::tsv);

    std::ifstream fin("data.csv");
    for(auto itr = csv::begin(fin, csv::mode::csv); itr != csv::end(fin); ++itr){
        *oitr++ = *itr;
        if(itr.eol())oitr.endl();
    }

    system("pause");

    return 0;
}

動機

カンマで一行が複数のフィールドに区切られたファイルのことをcsvといいます。カンマの代わりにスペースやタブで区切られたファイルも、それぞれssv、tsvと呼ばれ、しばしば使われるようです。

フォーマットは非常に単純なので、入出力は簡単です。入力は、一行ずつ読んではboost::algorithm::split関数あたりを使うのが一番効率がいいでしょう。出力は、フィールド間に区切り文字を出力するだけです。お手軽ですね。

ただ、csv系のファイルはたまに読み書きする機会があるのですが、以下のような不満点がありました。

  • 入出力を一貫した方法で書けない
  • 似たような処理を、あちこちに繰り返し書く必要があり格好悪い

ファイル中に含まれるフィールドを丸ごとコンテナとして確保するようなクラスを書けばいいのかもしれません(例えば、こんなクラス等)が、もうちょっと小粒で入出力内容自体の処理に集中できる、csv系ファイル入出力用のラッパークラスが欲しくなりました。

ということで、csvのフィールドを iterator っぽく扱えるクラスを作ってみました。

出力の設計

まず、出力です。output_iteratorっぽく使いたいので、以下のようにつかることが望ましいです。

std::vector<std::string> StringSet = {"Red", "Blue", "Green", ...};

std::ofstream fout("test.csv");
auto oitr = csv::begin(fout);

//StringSet配列内の文字列を順に出力
for(auto Str: StringSet){
   *oitr = Str;
   ++oitr;   //次のフィールドへ
}

//改行はendlメンバ関数
oitr.endl();

さて、それではさっそく作ってみます。std::iteratorを継承して、以下のようなoutput_iteratorクラスを作ります。

template<class Elem = char, class Traits = std::char_traits<Elem> >
class basic_ocsv_iterator : public std::iterator < std::output_iterator_tag, void, void, void, void >{
private:
    using my_type = basic_ocsv_iterator < Elem, Traits >;
    using ostream_type = std::basic_ostream < Elem, Traits >;
    using string_type = std::basic_string < Elem, Traits >;
    using csv_mode_type = basic_csv_mode<Elem, Traits>;
private:
    struct output_cell;
private:
    ostream_type* pstream;
    csv_mode_type CSVMode;
    bool IsCellHead;
    bool IsLineHead;
public:
    basic_ocsv_iterator()
        : pstream(nullptr){}
    basic_ocsv_iterator(ostream_type& Stream_, csv_mode_type CSVMode_)
        : pstream(&Stream_)
        , CSVMode(CSVMode_)
        , IsCellHead(false)
        , IsLineHead(true){}
    basic_ocsv_iterator(const my_type& My_) = default;
    my_type& operator=(const my_type& My_) = default;
public:
    operator bool()const{ return pstream; }
    output_cell operator*(){ return output_cell(*this); }
    my_type& operator++(){
        IsCellHead = true;
        IsLineHead = false;
        return *this;
    }
    my_type operator++(int){
        my_type ans(*this);
        operator++();
        return ans;
    }
    bool eol()const{ return IsLineHead; }
    void endl(){
        (*pstream) << CSVMode.End;
        IsLineHead = true;
        IsCellHead = false;
    }
};

operator++では、セル/行の先頭かどうかだけ覚えておいて、実際の入出力は、operator*関数の戻り値である、output_cellクラスに丸投げしてやります。endl関数で改行、eol関数で改行済みかどうかの確認ができます。

output_cellクラスの中身はこんな感じです。

struct output_cell{
private:
    my_type& My;
private:
    void cell_head_treat(){
        if(My.IsCellHead && !My.IsLineHead){
            *(My.pstream) << My.CSVMode.Sep;
        }
        My.IsCellHead = false;
    }
public:
    output_cell(my_type& My_) :My(My_){}
    void operator=(const string_type& Str){
        cell_head_treat();
        //エスケープ文字周りの処理
        *(My.pstream) << csv::cell_encode(Str, My.CSVMode);
    }
    template<typename T>
    output_cell& operator<<(const T& Val){
        cell_head_treat();
        *(My.pstream) << Val;
        return *this;
    }
    friend std::basic_istream<Elem, Traits>& operator>>(std::basic_istream<Elem, Traits>& in, output_cell p){
        string_type Str;
        in >> Str;
        p = Str;
        return in;
    }
};

std::stringクラスを代入演算子で受けて出力するほか、operator<<で任意の型のクラスも出力できるようにしてやります。こうしておくことで、以下のように非string型のクラスも出力できます。

std::ofstream fout("test.csv");
auto oitr = csv::begin(fout);

(*oitr++)<<32;
(*oitr++)<<35;
(*oitr++)<<40;

//出力:32,35,40

入力の設計

一方、入力はフィールドの読み込みがあるため、もう少しややこしくなっています。以下のように使えることを目指します。

std::ifstream fin("data.csv");

int Cell=0;
int Line=0;
for( auto itr = csv::begin(fin); itr!= csv::end(fin); ++itr){
   std::cout<<"["<<Line<<","<<Cell<<"]: "<<*itr;
   if(itr.eol()){
      Cell=0;
      Line++;
   }else{
      Cell++;
   }
}

以下のような、input_iterator形式のクラスとして定義してみます。

template<class Elem = char, class Traits = std::char_traits<Elem> >
class basic_icsv_iterator : public std::iterator < std::input_iterator_tag, std::basic_string<Elem, Traits>>{
private:
    using my_type = basic_icsv_iterator < Elem, Traits >;
public:
    using sstream_type = std::basic_stringstream < Elem, Traits >;
    using istream_type = std::basic_istream < Elem, Traits >;
    using pos_type = typename istream_type::pos_type;
    using string_type = std::basic_string < Elem, Traits >;
    using csv_mode_type = basic_csv_mode<Elem, Traits>;
private:
    istream_type* pstream;
    csv_mode_type CSVMode;
    pos_type Pos;
    sstream_type sstream;
    bool HasRead;
    pos_type ReadPos;
    bool IsLineHead;
private:
    //次のフィールドまでistreamの読み込み位置を進める
    void next(){
        if(HasRead){
            HasRead = false;
            Pos = ReadPos;
        } else{
            Elem c;
            pstream->seekg(0, std::ios::end);
            auto EndPos = pstream->tellg();
            pstream->seekg(Pos);

            bool EscFlag = false;
            while(pstream->tellg() != EndPos){
                c = pstream->get();

                if(c == CSVMode.End || c == EOF){
                    IsLineHead = true;
                    break;
                }

                if(c == CSVMode.Sep && !EscFlag){
                    IsLineHead = false;
                    break;
                }

                if(c == CSVMode.Esc){
                    EscFlag = !EscFlag;
                }
            }

            Pos = pstream->tellg();
        }
    }
    //次のフィールドの内容を読み込む
    sstream_type& read(){
        if(!HasRead){
            Elem c;
            sstream.str(string_type());
            sstream.clear();

            pstream->seekg(0, std::ios::end);
            auto EndPos = pstream->tellg();
            pstream->seekg(Pos);

            bool EscFlag = false;
            while(pstream->tellg() != EndPos){
                c = pstream->get();

                if(c == CSVMode.End || c == EOF){
                    IsLineHead = true;
                    break;
                }

                if(c == CSVMode.Sep && !EscFlag){
                    IsLineHead = false;
                    break;
                }

                if(c == CSVMode.Esc){
                    EscFlag = !EscFlag;
                }

                sstream << c;
            }

            ReadPos = pstream->tellg();
            HasRead = true;
        }
        return sstream;
    }
private:
    struct input_cell;
public:
    basic_icsv_iterator() :pstream(nullptr){}
    basic_icsv_iterator(istream_type& Stream_, pos_type Pos_, csv_mode_type CSVMode_, bool IsLineHead_)
        : pstream(&Stream_)
        , CSVMode(CSVMode_)
        , Pos(Pos_)
        , sstream()
        , HasRead(false)
        , IsLineHead(IsLineHead_){}
    input_cell operator*(){ return input_cell(*this); }
    my_type& operator++(){
        next();
        return *this;
    }
    my_type operator++(int){
        basic_icsv_iterator ans(*this);
        operator++();
        return ans;
    }
    bool eol()const{ return IsLineHead; }
    bool eof(){
        pstream->seekg(0, std::ios::end);
        return Pos == pstream->tellg();
    }
    friend bool operator==(const my_type& my1, const my_type& my2){
        if(&my1 == &my2)return true;

        if(my1.pstream != my2.pstream) return false;

        return my1.Pos == my2.Pos;
    }
    friend bool operator!=(const my_type& my1, const my_type& my2){ return !(my1 == my2); }
};

非常に汚い感じですが、read関数で次のフィールドを読み進めています。一方、next関数は単純に次のフィールドまで読み込み位置を移動させるだけで、すでにread関数で読み込み済みなら、その位置までistream::seekg関数で読み込み位置を移動させています。

あとは、出力時同様、operator++で読み込むフィールドを進め、operator*で戻すinput_cellに処理は丸投げしています。その他、eol、eofで行やファイルの末端を検知できるようになっています。

input_cellの中身は以下のような感じです。

struct input_cell{
private:
    my_type& My;
public:
    input_cell(my_type& My_) :My(My_){}
    operator string_type(){ return csv::cell_decode(My.read().str(), My.CSVMode); }
    template<typename T>
    input_cell& operator>>(T& Val){
        My.read() >> Val;
        return *this;
    }
    friend std::basic_ostream<Elem, Traits>& operator<<(std::basic_ostream<Elem, Traits>& out, input_cell p){
        return out << static_cast<string_type>(p);
    }
    friend bool operator==(const input_cell& in, const string_type& str){return in.raw_str() == str;}
    friend bool operator!=(const input_cell& in, const string_type& str){ return in.raw_str() != str; }
};

string型への暗黙のキャストを許すことで、文字列として読み込めるほか、operator>>で指定した型への読み込みができます。

std::ifstream fin("data.csv");
auto itr = csv::begin(fin);
double a;
int b;

//doubleで読み込み
(*itr++)>>a;

//intで読み込み
(*itr++)>>b;

完成 そして使ってみる

その他、begin関数やend関数を整備してやると、記事の最初に書いたようなコード(csv_iterator.hpp)が出来上がります。

さっそく使ってみます。例えば、以下のようなデータがあったとします(データ)。

Name Sex Age
Satoshi M 23
Toshiko F 25
Aya F 52

読み出して、一列ずつコンテナに入れようと思うと、以下のような感じで書けます(ソース)。

#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include "csv_iterator.hpp"

int main(){
    std::vector<std::string> Names;
    std::vector<bool> IsFemales;
    std::vector<int> Ages;

    std::ifstream fin("data.csv");
    auto itr = csv::begin(fin, csv::mode::csv);

    //1行読み飛ばす
    csv::advance_line(itr);

    //名前、性別、年齢の順で読みだす
    while(itr != csv::end(fin)){
        //文字列なら直接読み出し可能
        Names.push_back(*itr++);
        if(itr.eol())continue;

        //当然、文字列との比較も可能
        IsFemales.push_back(*itr++ == "F");
        if(itr.eol())continue;

        //型を指定して読み出すことも可能
        Ages.push_back((*itr++).read<int>());
    }

    std::cout << "=name=" << std::endl;
    for(const auto& val : Names)std::cout << val << std::endl;
    std::cout << std::endl;

    std::cout << "=sex=" << std::endl;
    for(auto val : IsFemales)std::cout << val << std::endl;
    std::cout << std::endl;

    std::cout << "=age=" << std::endl;
    for(auto val : Ages)std::cout << val << std::endl;
    std::cout << std::endl;

    system("pause");

    return 0;
}

あるいは、こんなデータはどうでしょう(データ)。

Num Data1 Data2 Data3 Data4...
3 Red Blue Yellow
1 Tokyo
2 Apple Banana
4 North West South East
2 Dog Cat

こんな感じのジャギーなデータも、iteratorなら扱うのは比較的簡単です(ソース)。

#include <iostream>
#include <fstream>
#include "csv_iterator.hpp"

int main(){
    std::ifstream fin("data2.csv");
    auto itr = csv::begin(fin, csv::mode::csv);

    //1行読み飛ばす
    csv::advance_line(itr);

    //名前、性別、年齢の順で読みだす
    while(itr != csv::end(fin)){
        unsigned int Num;
        (*itr++) >> Num;

        for(unsigned int Cnt = 0; Cnt < Num; ++Cnt){
            std::cout << "[" << Cnt << "/" << Num << "]" << (*itr++) <<std::endl;
        }
    }

    system("pause");

    return 0;
}

まとめ

以上、CSVフォーマットのファイルをiteratorチックにいじってみるお話でした。
なかなか自分でもぬるいコードだと思うので、突っ込み・コメント等大歓迎です。

この記事は、C++ Advent Calendar 2015 の10日目の記事でした。
明日は、lifeaetherさんの記事、「Objective-C インスタンス変数の C++ オブジェクト」です。