ホーム < ゲームつくろー! < 新・ゲーム制作技術編

その2 CSVデータテーブルをプログラム上で使えるようにする


 前章で、Excelなどの表計算ソフトに記述したデータテーブルをCSV出力しました。この章ではそのCSVをプログラムで読み込み、プログラム上で扱えるようにします。 



@ Tableクラス

 一つのCSVデータテーブルを扱うクラスをTableクラスとしておきます。前章のCSVデータはこんな感じで:

CSVデータテーブル(test.txt)
6
3
id,name,attribute,price,attack,defence
item_w_0001,木刀,weapon,100,10,0
item_w_0002,銅の剣,weapon,500,20,0
item_w_0003,鉄の剣,weapon,1200,35,0

1行目は列数(項目数)、2行目は行数(レコード数)です。3行目は項目名で、これは後で値を取り出すためのキーとなります。テーブルルールとして、1項目目(id)がレコードを検索するキーとして使われるとします。

 Tableクラスを実装する前に、実際にTableクラスを使って上の項目にアクセスする部分を考えてみます。まずtest.txtファイルを開いてCSVを解析しデータをメモリに格納します。次に1項目目のidにある名前、例えば「item_w_0002」を指定します。これでこの1行(レコード)を取得できます。次にそのレコードに対して「name」等と名前でアクセスすると「銅の剣」が得られます。以上をプログラムでなるべく簡潔に書くとこうなります:

実装目標!
Table table("test.txt");

std::string name = table["item_w_0002"]["name"];

テーブルを登録して、配列演算子で即アクセス!これだと簡単です。今回はこれを目指します。

 Tableクラスのコンストラクタでは開くべきファイル名を渡します。コンストラクタの中で解析は終わらせてしまいます。配列演算子は2次元配列のようになっていますが、実際には最初の["item_w_0002"]でレコードクラス(Record)のオブジェクトを取得しています。Recordクラスも配列演算子を持つため、続けて["name"]でレコードのname項目にアクセスできます。Record::operator[]が返すのは文字列だけとは限りません。整数(int)と浮動小数点(float)は欲しい所です。データテーブルをみるとそういうデータが混在するのがわかると思います。そこでそれらのデータをまとめて扱う簡易なVariant型を定義しておきます。以上の宣言部はこんな感じになるでしょうか:

table.h
class Table {
public:
    ///////////////////////////
    // Variant
    /////
    struct Variant {
        int iVal;
        float fVal;
        std::string str;
        Variant() : iVal(), fVal() {}
        Variant(const std::string &s) : iVal(atoi(s.c_str())), fVal((float)atof(s.c_str())), str(s) {}
        operator std::string() { return str; }
        operator int() { return iVal; }
        operator float() { return fVal; }
    };


    ///////////////////////////
    // Record
    /////
    class Record {
       static Variant nullVariant;
        std::map<std::string, Variant*> vals;

    public:
        Record() {}
        ~Record();
        Table::Variant& operator [](const char* key);
        void add(const char* key, Variant &val);
    };


    ////////////////////////////
    // Table
    /////
private:
    Table(const Table &src);
    Table& operator =(const Table &src);


protected:
    static Record nullRecord;
    std::map<std::string, Record*> records;

public:
    Table(const char* filePath);
    virtual ~Table();

    // レコード取得
    Record& operator[](const char* key);
};

 Tableクラスはrecordsというマップを持っています。このkeyにデータテーブルの1項目目(id)が来て、2項目目以降がvalueとしてRecordに格納されるわけです。マップのvalue引数が「Record*」とポインタになっているのに注目です。これは実体にしてしまうとコンテナ管理中に激しいディープコピーが起きてしまうためです。テーブルは巨大なのででかいコピーが起こるとまずいためポインタにしています。

 値をポインタで保持するという事は、レコードが登録される度にヒープからnewする事になります。この寿命はTableオブジェクトが無くなった時なので、デストラクタで削除します。それ自体は難しくありませんが、怖いのがTableのコピーです。Tableをコピーするとrecordsマップは共有されますが、デストラクタは2回呼ばれます。1回目で消えた後、2回目でさらに消そうとするため、メモリ保護違反で落ちます!そのためTableオブジェクトは「ディープコピーを禁止」しなければならないんです。そのため、Tableクラスのコピーコンストラクタと代入演算子がprivate実装になっています。これはNoncopyableの実装です(Noncopyableについてはクラス構築編「コピー禁止を徹底させるNoncopyableクラス」を参照下さい)。

 TableクラスにstaticなRecordオブジェクト(nullRecord)が一つ設けられていますね。これは、配列演算子でのアクセスでNullアクセスを防止するために設けています。つまり、

キーが無くても何とかなる
Table table;
Table::Record& rec = table["hoge"];

と未登録のキーhogeでRecordを取得しても戻り値がnullRecordならば値は適当ですが実体は保障されます。もちろんキーが存在するかチェックするメソッドを設けるのも得策です。

 Tableクラスの実装はこんな感じになりました:

Tableクラスの実装(table.cpp)
//////////////////////////
// Table
/////
Table::Record Table::nullRecord;

Table::Table(const char* filePath) {
    std::ifstream ifs(filePath);
    if (ifs.is_open() == true) {
        // 行列数取得
        int col = 0, row = 0;
        char s[2048] = {};
        ifs.getline(s, 2048);
        col = atoi(s);
        ifs.getline(s, 2048);
        row = atoi(s);

        // データKey取得
        std::vector<std::string> dataKeys;
        Context<2048> ct;
        ifs.getline(ct.str, 2048);
        for (int c = 0; c < col; c++)
            dataKeys.push_back( ct.getAndNext() );

        // データ取得
        ct.reset();
        for (int r = 0; r < row; r++) {
            ifs.getline(ct.str, 2048);
            Record *rec = new Record();
            std::string key = ct.getAndNext();
            for (int c = 1; c < col; c++)
                rec->add( dataKeys[c].c_str(), Variant(ct.getAndNext()) );
            records[key] = rec;
            ct.reset();
        }
    }
}

Table::~Table() {
    std::map<std::string, Record*>::iterator it;
    for (it = records.begin(); it != records.end(); it++)
        delete it->second;
}

Table::Record& Table::operator[](const char* key) {
    if (records.find(key) == records.end())
        return nullRecord;
    return *records[key];
}

コンストラクタがほぼすべてです。コンストラクタではファイルをオープンして1行ずつ読み込み値を取り出しています。実装内にContextというクラスが出てきます。これはCSVの簡易パーサーで次のように実装しました:

Contextクラス
template<int N>
class Context {
public:
    char str[N];

private:
    int size;
    int pos;

public:
    Context() : size(N), pos() {}

    // パラメータを取得してポインタを先に進める
    std::string getAndNext() {
        char c[N] = {};
        int p = 0;
        for (int i = pos; i < size; i++, p++, pos++) {
            if (str[i] == ',' || str[i] == '\0') {
                pos++;
                return c;
            }
            c[p] = str[i];
        }
        return c;
    }

    // リセット
    void reset() {
        pos = 0;
    }
};

本当に愚直にCSVでセパレートしているだけです。getAndNextメソッドを呼び出すと今の文字位置から次のコンマまでの文字列を切り出して返すと同時に文字ポイントを次の位置にセットし直します。これでgetAndNextメソッドを呼び出す度にCSVデータが1データずつ取り出せるわけです。Table::Tableコンストラクタ内のデータ取得部分でこのクラスが大いに活躍してくれます(^-^)

 Tableクラスのコンストラクタを通ると、CSVデータテーブルはすべてメモリ上に乗ります。後はそのアクセスメソッド(配列演算子)を実装するだけです。上記にあるように実装は極めて簡単ですが、keyが無かった時にnullRecordへの参照を返すのが特徴ですね。



A Recordクラス

 1レコードを表すRecordクラスはCSVデータテーブルの2項目目以降の値を保持します(1項目目はTableクラスのrecordsマップのKey)。実装を見てみましょう:

Recordクラス
//////////////////////////
// Record
/////
Table::Variant Table::Record::nullVariant;

Table::Record::~Record() {
    std::map<std::string, Variant*>::iterator it;
    for (it = vals.begin(); it != vals.end(); it++)
        delete it->second;
}

Table::Variant& Table::Record::operator[](const char* key) {
    if (vals.find(key) == vals.end())
        return nullVariant;
    return *vals[key];
}

void Table::Record::add(const char* key, Variant &val) {
    if (vals.find(key) == vals.end())
        vals[key] = new Variant(val);
    else
        (*vals[key]) = val;
}

Recordクラスは登録用のaddメソッドと値を取り出す配列演算子しか主たる所はありません。valsマップはstd::map<std::string, Variant*>と、項目名をキーとしその値へアクセスするマップです。これもVariant*とポインタになっています。理由はTableの時と同じです。

 登録と取得はmapへ値を登録したり取得したりするのをラップしているだけですが、ちょっと注意が必要です。addメソッドの実装を見て下さい。登録時同じキーがあった場合、Variantポインタ変数を上書きすると前のVariantポインタ先がリークしてしまいます。そのため、同じキーがあった場合は実体を上書き変更してしまっています。

 取得については未登録なkeyを指定した時にnullVariantへの参照を返しています。これもTableクラスの時と同じですね。Recordはこんなもんです。



B Variant構造体

 Variant構造体は整数(int)、浮動小数点(float)そして文字列(std::string)を保持してレコード内の1データ情報を表現する値クラスです。実装はすでに@にあります。

 単なる値保持構造体ですが、ちょっと楽をするためにint型、float型そしてstd::string型が左辺に来た時に対応する値を返すような演算子を定義しています:

Variant構造体
   ///////////////////////////
    // Variant
    /////
    struct Variant {
        int iVal;
        float fVal;
        std::string str;
        Variant() : iVal(), fVal() {}
        Variant(const std::string &s) : iVal(atoi(s.c_str())), fVal((float)atof(s.c_str())), str(s) {}
        operator std::string() { return str; }
        operator int() { return iVal; }
        operator float() { return fVal; }
    };


これによって、一々「int val = table["hoge"]["foo"].iVal」のように対応するメンバを指定しなくて良くなります。

 値を入れる方法は直接入れても良いのですが、CSVを解析した結果は必ず文字列になってしまうため、文字列を引数にとるコンストラクタを設けました。大変べたな方法ですが楽はできます(^-^)

 Tableクラスとそれに関連するクラスの実装は以上です。割とシンプルにできますが、これで大量のデータが格納されているテーブルから必要な情報をガンガン取得できるようになるわけですから中々の前進です(^-^)/

 Tableクラスについてはサンプルプログラムにて公開致しますので、ご自由にお使い下さい。



C 1データ配列について

 今回のデータテーブルは実質2次元配列です。でも、例えばSTGで敵が爆発した時に毎回同じSEではなくて何種類かの中からランダムに選ばれて鳴らしたいという事がある場合、「ExplosionSE」という項目の中に複数のSE名を並べたくなります。1データ内が配列になっているわけです。Tableクラスでこれを実現する場合、CSVの書式拡張とパーサーの拡張が必要になります。この拡張は有効ではありますが、Excel側で配列部分を手書きする必要が出てきます。Excelは1セルを分割出来ないからです。

 個人的には1データ配列は冗長かなぁと思っていますが、テーブルサイズを節約したい時には必要になるかもしれません。まぁ、無くても何とかなります(^-^;。