石原高のオレ流C++独学塾 - 第3回 CSVファイルを処理してみる

By: Hitoshi Fujii

Abstract: 文字列を探求する中で、少し実用的なプログラムを書いてみることにする。CSVファイルを読み込んで、特定の列に一致する文字列がある行だけを取り出す。

Hide image
ishihara

「おまえはもうコードを書くな」と上司にいわれてはや3年。管理職業務も板についてきた今日この頃だが、オレ的には、やはり手がうずく。軽快にコードを入力してEnterキーのストロークを決めたい。

ものの記事によると、今組み込み市場がホットで、CやC++の需要が高まっているらしい。N88 BASICでプログラミングを始めて、仕事じゃFORTRANやCを使ってきたオレとしては、最近の言語はどうもなじまない。どうせオブジェクト指向やるなら、オレ流としては風呂釜にJavaじゃなくてC++だろう。管理職のオレ様としては、背中は窓でうしろに立つものはいないし、こっそり残業してC++でも始めてみるか。


    少し実用的なプログラムを書いてみるか

文字列の探求をしてみて、C++Builderでは、AnsiStringというものがあり、これを理解すれば、それなりにプログラムが書けそうな気になってきた。しかし一方で、C++の世界には、std::stringなるものもあり、どの文字列型を使うのか、実際のプログラムで考えてみるほうがよいような気がしてきた。

選択肢が多いのはよいこともあるが、時として混乱を招く。混乱しないためにも、いろいろな選択肢の特性を知っておくのがよかろう。

この飽食の時代、何かを作れといわれても、満ち足りていてこれといって思いつかないものだ。オレとしては、文字列処理が中心のファイル操作のようなものがいいのだが。

あれば便利、なくても困らないという種類のものだが、ときどきデータベース用のデータとしてCSVファイルを扱うオレにとって、CSVファイルの処理プログラムというのは、よさそうな課題に思える。あんまり高級なことをやっても完成しないので、まずはCSVファイルを読み込んで、特定の列に指定した値があるデータのみを取り出すプログラムを書いてみようと思う。

なんといっても試行錯誤だ。いきなり完全なものを目指すのではなく、コンセプトをカタチにすることからはじめるぜ。

    プログラムの概要

ざっと、仕様を考えてみよう。CSVファイル1行目のヘッダ行を読み込み、項目名を取得する。これをリストかなにかで選択させて、検索するテキストを入力させる。ボタンを押すと、CSVファイルを読み込んで、選択した列に指定したテキストがあるかどうかを調べて、あれば出力、なければ次の行とファイルの終わりまで処理を進める。

ということで、このプログラムには以下の3つのステップがあるわけだ。

  1. CSVファイルヘッダの読み込み
  2. 列の選択と検索テキストの入力
  3. ファイルの出力

プログラム的には、読み込んだテキストを列に分解するところがポイントになりそうだ。一般的な機能としては、こんなところが挑戦すべき課題だ。

  • ファイル入出力
  • 文字列の切り出し
  • 文字列の検索

使用するコンポーネントは、TListBox、TEdit、TButtonあたりか。検索条件に「完全一致」か「部分一致」かを選べるようにするんだったら、TRadioGroupを使うのがよいかもしれない。

懸案の文字列操作は後回しにして、とりあえずユーザーインターフェイスを作ってしまうことにしよう。

    ユーザーインターフェイスの設計

新規VCLアプリケーションを作成し、フォームにコンポーネントを配置する。レイアウトはお好みだが、オレは図のようにした。

Hide image
cb03_1

図1 ユーザーインターフェイスの設計

プロパティを設定する。

Button1

プロパティ

Name

btnLoad

Caption

ファイルを読み込む


Label1

プロパティ

Caption

フィールド


ListBox1

プロパティ

Name

lstFields


Label2

プロパティ

Caption

条件


Edit1

プロパティ

Name

edtCondition

Text

(ブランク)


RadioGroup1

プロパティ

Name

rdgOption

Caption

オプション

Items(*)

完全一致
部分一致

ItemIndex

0


(*) Itemsプロパティを入力するには、Itemsプロパティ値列の[…]をクリックして、「文字列リストの設定」ダイアログに上記値を2行にわたって入力します。

Button2

プロパティ

Name

btnExecute

Caption

実行


Button3

プロパティ

Name

btnQuit

Caption

終了


    ファイルを読み込む手順

ファイルを読み込むには、次のような手順を踏む。

  1. ファイルを選択する
  2. ファイルを開く
  3. ファイルから1行読み込む
  4. 読み込んだテキストを分解して、列ごとにリストに追加する

コマンドラインプログラムなら、ファイル名は引数からもらえばよいと簡単に考えるが、GUIアプリケーションではそうもいかない。ボタンをクリックしたらファイルを選択するダイアログを開きたい。

調べたらいくつかのダイアログコンポーネントというのがあり、簡単に利用できるようだ。コンポーネントをフォームの上に置いて、Executeとやればよいのだ。

ここで使うのはTOpenDialogだ。コンポーネントをフォームの上に置くと、ツールボタンのようなアイコンが配置された。「フォームの上に置く」という操作がまぎらわしいが、要はこのフォームクラスのメンバーとして追加したということだ。ヘッダファイルには、以下の1行が追加される。

TOpenDialog *OpenDialog1;

自分で宣言したときとの違いは、フォームが生成されたときにいっしょに生成されていることだ。つまり、Buttonなどと同じように、new しないで、いきなり、

OpenDialog1->Execute();

とできるというわけだ。Executeは、ダイアログボックスでファイルを選択したとき、つまりは[開く]ボタンをクリックしたときに、trueを返す。従って、btnLoadボタンのOnClickイベントに、次のように書けばよい。

void __fastcall TCSVExtract::btnLoadClick(TObject *Sender)
{
    if( OpenDialog1->Execute() ) {
        // ファイルを読み込む
        
    }

}

さて、肝心のファイルを読み込む処理だが、ここはとりあえずC++のファイル入出力を使ってみようと思う。1行読み込む処理なのだから、昔ながらのfopen、fgetsでもかまわないが、とりあえず新しいものに慣れることにする。

void __fastcall TCSVExtract::btnLoadClick(TObject *Sender)
{
    if( OpenDialog1->Execute() ) {
        std::ifstream in(OpenDialog1->FileName.c_str());
        // ファイルを1行だけ読み込む
        if(in.is_open()) {
            char buf[1024];  // 便宜上1行は1024文字未満
            in.getline(buf, 1024);
            // TODO - 読み込んだCSVテキストをListBoxに展開する処理を書く
        }
        else {
            MessageDlg("ファイル読み込みエラー: " + OpenDialog1->FileName,
              mtError, TMsgDlgButtons() << mbOK, 0);
        }
    }

}

結局バッファリングをどうするかというのは、常に悩ましく、ここでは、便宜上1行は、1024文字以内として逃げてしまった。まあコンセプトレベルのプログラムだから許してくれ。まずは、そこそこ動くものを目指したいのだ。

ところで、ファイル入力に使ったstd::ifstreamだが、コンストラクタにファイル名を指定する。引数に許容されるのは、std::stringかchar *だったのだが、あいにくOpenDialogで選択したファイルの名前は、AnsiString型のプロパティFileNameに保持されている。こんなときは、迷わず前回覚えたc_strを使う。

これは、コンパイルエラーになるが、

std::ifstream in(OpenDialog1->FileName);

こちらならOKだ。

std::ifstream in(OpenDialog1->FileName.c_str());

簡単にステップをまとめよう。

std::ifstream in(ファイル名);

でファイルを開く。これは、std::ifstreamのコンストラクタだ。ファイル名は、OpenDialogのFileNameプロパティを使うが、c_strでchar * 型で指定する。ファイルがちゃんとオープンできているかは、

in.is_open()

で調べる。オープンできていたら、

in.getline(バッファ、最大サイズ);

で1行読み込む。std::ifstreamを使ったときは、ファイルを明示的にクローズしなくていいらしい。これは、fopenより楽だ。

さて、「読み込んだCSVテキストをListBoxに展開する処理」が残っている。どうもstd::stringやAnsiStringでは、気の利いたコードが書けそうにないので、コテコテのC言語の処理を書いておくことにする。これもコンセプトレベルだ。いいではないか。

    CSVの1行をリストに分解する

ListBoxの項目は、Itemsに保持されている。このItemsとは、TStringsという文字列リストのようだ。もっともTStringsは抽象クラスで、実際に文字列リストを作るには、TStringListを使う。

CSVの1行を分解する処理は、2箇所で使う。1回目は、ヘッダを読み込みListBoxの項目に展開するとき。2回目は、各行を読み込み、条件に合致するかどうかを調べるときである。後者の処理に、TStringListを使ってしまえば、両方のCSVデータの分解をTStringsで扱えるだろう。

ところで、TStringsを調べていくうちに、TStringsには、CommaTextという便利なプロパティがあることが分かった。ここにコンマ区切りのテキストを与えれば、自動的にリストに展開されるらしい。

なんだ出来てしまったではないかと喜んだが、ぬか喜びだった。CommaTextの仕様は、ブランクも区切りと認識するらしい。ダブルクォーテーションで括っていないと、ブランクを区切りとして認識してしまう。なかなかうまくいかないものだ。

TStringsにこのような処理が付随しているように、そもそもデータを加工する処理は、そのクラスに付随しているものなのだろう。これがオブジェクト指向というものだ。恐らく。

だが、今はとりあえず、C言語的に、その辺に処理コードを書きなぐっておく。

void TCSVExtract::SetCSVText(TStrings *list, const char *src)
{
    char buf[1024]; // 便宜上1行は1024文字未満

    list->BeginUpdate();
    list->Clear();

    strcpy(buf, src);
    for (char *ptr = buf, *nptr; ptr && *ptr != '\0'; ptr = nptr) {
        nptr = strchr(ptr, ',');
        if (nptr) {
            *nptr = '\0';
            nptr++;
        }
        list->Add(ptr);
    }
    list->EndUpdate();
}

なんとも原始的なポインタ処理だが、まあ動くのだからいいだろう。コンマを探して、区切り文字(’\0’)に置き換えてリストに追加する処理を繰り返しているだけだ。ユニコードだなんだといってくると、こうはいかなくなるだろうが、洗練された処理というものはこれから学んでいくのだ。

ところで、処理の最初と最後にBeginUpdate、EndUpdateをそれぞれ呼んでいるが、これは重要なポイントだ。データが画面のコントロールに関係のあるプロパティだったりすると、データをひとつ更新しただけで、コントロールに更新がかかってしまう恐れがある。BeginUpdate~EndUpdateの間は、「更新中」と宣言するようなもんで、コントロールにはEndUpdate以降、まとめて更新がかかるらしい。面倒な処理をやっていてもこれなら大丈夫だ。こういうテクニックは重要だ。

では、このコードを呼び出す。

void __fastcall TCSVExtract::btnLoadClick(TObject *Sender)
{
    if( OpenDialog1->Execute() ) {
        std::ifstream in(OpenDialog1->FileName.c_str());
        // ファイルを1行だけ読み込む
        if(in.is_open()) {
            char buf[1024];  // 便宜上1行は1024文字未満
            in.getline(buf, 1024);
            // 読み込んだCSVテキストをListBoxに展開
            SetCSVText(lstFields->Items, buf);
        }
        else {
            MessageDlg("ファイル読み込みエラー: " + OpenDialog1->FileName,
              mtError, TMsgDlgButtons() << mbOK, 0);
        }
    }

}

ちょっとしたCSVファイルを使って、読み込みテストをしてみよう。

Hide image
cb03_2

図2 CSVファイルの読み込み

    ファイルの書き出し

ファイル書き出しでは、保存用のファイルを指定しなければならない。ここでは、TSaveDialogを使う。先ほどと同じように、コンポーネントを配置して利用する。ファイルの書き出しは、std::ofstreamを使う。使い方は、std::ifstream のときと同じだ。

std::ofstream out(SaveDialog1->FileName.c_str());

読み込んだCSVファイルの1行分のデータを展開するのに、今度は、TStringListを使う。TStringListは、newを使って作成しなければならない。

TStringList *items = new TStringList;

これは忘れずに解放しないと面倒なことになる。生成してから使い終わるまでをtry {} catch(...)で囲んで、必ずdeleteを通るようにする。

これが、その実装コードだ。ちょっとネストが深いが我慢してくれ。よいコードはそのうち書けるだろう。はじめの lstFields->ItemIndex < 0 で、リストでフィールドを選んでいるかどうかをチェックしている。選んでいなければ -1 を返すので、メッセージを表示して抜けている。

void __fastcall TCSVExtract::btnExecuteClick(TObject *Sender)
{
    if (lstFields->ItemIndex < 0) {
        MessageDlg("フィールドが選択されていません.",
            mtError, TMsgDlgButtons() << mbOK, 0);
        return;
    }

    // ファイルを開く
    if( SaveDialog1->Execute() ) {
        std::ifstream in(OpenDialog1->FileName.c_str());
        std::ofstream out(SaveDialog1->FileName.c_str());
        if (in.is_open() && out.is_open()) {
            char buf[1024];  // 便宜上1行は1024文字未満
            // ヘッダ行はそのまま書き出す
            in.getline(buf, 1024);
            out << buf << std::endl;

            // 項目分離用の変数 - 最後に必ず解放
            TStringList *items = new TStringList;
            // 指定された項目を含む行だけ書き出す
            try {
                while (!in.eof()) {
                    in.getline(buf, 1024);
                    SetCSVText(items, buf);
                    if (items->Count > lstFields->ItemIndex) {
                        // 完全一致
                        if (rdgOption->ItemIndex == 0) {
                            if(items->Strings[lstFields->ItemIndex] == edtCondition->Text)
                                out << buf << std::endl;
                        }
                        // 部分一致
                        else {
                            if(items->Strings[lstFields->ItemIndex].Pos(edtCondition->Text) > 0)
                                out << buf << std::endl;
                        }
                    }
                }
            } catch (...) {
                MessageDlg("ファイル入出力に失敗しました.",
                    mtError, TMsgDlgButtons() << mbOK, 0);
            }
            delete items;
        }
        else {
            MessageDlg("ファイルオープンエラー.",
          mtError, TMsgDlgButtons() << mbOK, 0);
        }
    }
}

ところで、完全一致か部分一致かをrdgOption->ItemIndexをチェックするif文で判別しているが、この処理は、毎行読み込むたびに発生する。行数が多いと、こんなつまらないところの蓄積がオーバーヘッドになるものだ。ループの外にこのチェックを出してしまえば1ステップ節約できるが、コードは冗長になる。どちらがよいか悩むところだ。

    とりあえず完成

最後に、btnQuitのOnClickイベントにコードを記述する。これは単純だ。

void __fastcall TCSVExtract::btnQuitClick(TObject *Sender)
{
    Close();
}

なんとか動くものができたようだ。実行してみると、ちゃんと条件に合ったデータだけを抽出してくれる。

Hide image
cb03_3

図3 プログラムの実行

CSVファイルを読むまでは、[実行]ボタンは使えないようにしてやったほうが親切かと思ったので、早速コードを修正した。

void __fastcall TCSVExtract::btnLoadClick(TObject *Sender)
{
    if( OpenDialog1->Execute() ) {
        std::ifstream in(OpenDialog1->FileName.c_str());
        // ファイルを1行だけ読み込む
        if(in.is_open()) {
            char buf[1024];  // 便宜上1行は1024文字未満
            in.getline(buf, 1024);
            // 読み込んだCSVテキストをListBoxに展開
            SetCSVText(lstFields->Items, buf);
            btnExecute->Enabled = true;
        }
        else {
            MessageDlg("ファイル読み込みエラー: " + OpenDialog1->FileName,
              mtError, TMsgDlgButtons() << mbOK, 0);
        }
    }

}

これと、btnExecuteのプロパティも変更しておく。

btnExecute

プロパティ

Enabled

false


これで起動時には、ボタンは無効になっており、CSVファイルを読み込むと初めて有効になる。

Hide image
cb03_4

図4 初期設定でボタンを無効化

    若干の考察

テストデータは短かったので気にならないが、膨大なデータを処理すると、[実行]ボタンを押すと処理が終わるまで固まってしまう。キャンセルも効かない。誰にもうっかりミスというのはつきものだろう。気がついたらすぐにやり直しが利くようにしてやるというのが、やさしいインターフェイスというものだ。これをやるには、恐らく、マルチスレッドというのを考えなければならないのだろう。

それから、文字列処理は、細かいところになるとやはりポインタを使いたくなる。そうなると、char * を使わざるを得ない。しかし、C++BuilderのVCLコンポーネントでは、AnsiStringを使うので、どうしてもchar * と AnsiStringの変換が発生する。これは、std::stringを使った場合でも同様だろう。

文字列型を変換することは、当然オーバーヘッドを招くので避けたいところだが、場所によっていろいろな文字列型が混在するのも混乱を招く。「入り」と「出」をしっかり決めて、内部処理を隠蔽するような書き方が理想なんだろう。

VCLにも便利な機能があって、ごっそりファイルに書き出したり、ファイルから読み込んだりというメソッドがある。例えば、TListBoxもリスト項目をファイルから得るなんてこともできる。しかし、こちらの求めるものと少し違った場合には、これらのものは使えない。使えないからといってまったく別のものを作っていたのでは効率が悪いので、カスタマイズして使うという方法を考えていくべきなんだろう。まだ先は長いぞ。

Server Response from: ETNASC03