[C++] 簡単なシリアライザでも作ろうか

 8月です。圧倒的に時間が足りません。

 さて、それは置いといて、C++でシリアライズしたいシチュエーションってありますよね。例えばゲームのセーブデータとか、ゲームのセーブデータとか。あとはゲームのセーブデータとかかな。

 最初に答えをいってしまうと、まぁboost::serialization使えってことになるんですが、でもそれって面白く無いじゃないですか。boostでかいし、ちょっとシリアライズするために使うのもなんだかアレじゃないですか。まぁとりあえずそういうことにしといてください。

 というわけでサクッと簡単にシリアライズするクラスでも作ってみました(丸一日かかったなんて言えない)。
いわゆる車輪の再発明ってやつで、しかも汎用的に使うにはちょっと問題ありな感じになっています。所詮は私の勉強の記録なので、そこら辺はご了承ください。

方針

 今回は以下のような方針に沿って実装しました。

  • とにかく簡単なものを(バイトオーダーとか、そういうのは考えない方針で)
  • 簡単に扱えるように(テンプレートとか意識しない方向で)
  • Serializable(抽象クラス)とそうでないクラスで処理を分ける(後述)

 ざっとこんな感じで。Serializableについてですが、このクラスは抽象クラスであり、サブクラスでserializeメンバ関数を実装することで任意のメンバをシリアライズすることができます。実装は↓こんな感じですかね。

class Serializable
{
public:
    virtual void serialize(Archive &data) = 0;
};

 最初は「シリアライザなんてloadとsaveがあればいいんだろ!」とか考えてましたが、boostのserialization見たら、なんとメンバ関数一つでシリアライズもデシリアライズもできちゃうんですねー。まぁよく考えたらデータの書き込み読み込み順は固定されてるので、読み込みと書き込みで分ける必要なんて全然ないわけで。
 というわけで、boostオマージュな感じで上のような実装にしました。Archiveは後で実装するシリアライザクラスです。

 boostでは、上記のArchiveクラスに相当す部分がテンプレートになっていました。たぶんシリアライズを行うクラスのシリアライズを実際に行うメンバ関数がテンプレートメンバ関数として実装されているため、メンバ関数のオーバーライドによる読み書き処理の切り分けが出来ないためだと思われます。
 テンプレートを意識しなくても使えるようにするのが今回の方針でしたので、テンプレートクラスではなく普通のクラスになっています。

Serializableを継承したクラスとその他の型

 さて今回の方針の3番目、一番重要になるのが、このSerializableを継承したクラスとその他の型の区別です。普通に考えたら、汎用テンプレートメンバ関数を1つ作っておいて、Serializableの場合のみ特殊化すればうまくいきますよね。

template<typename T>
void serialize(T &object) { } // 汎用関数

// Serializableを継承したクラス用に特殊化
template<>
void serialize<Serializable>(Serializable &object) { }

 しかし、残念なことにこのコードは意図したとおりには動きません。具体的には、Serializableを継承したクラスを引数として渡しても、汎用テンプレート関数のほうが実体化されてしまいます。
 これは、C++がテンプレートの実体化をパターンマッチ的に行なっていることが原因です。つまり、Serializableクラスを継承したクラスであっても、わざわざテンプレート引数を指定しなければなりません。

class Something : public Serializable
{
public:
    void serialize(Archive &data) { }
};

// main関数内
Something s;
serialize<Serializable>(s);

 これはかっこよくないですし、使い勝手もよくないですし、演算子のオーバーロードだとうまく使えませんしおすし。

 さて、困ったことになりました。テンプレートの特殊化ではSerializableを継承したクラスの判別ができません。というわけで、だいぶトリッキーな実装をしなければなりません。トリッキーな実装で回避できちゃうのがC++のいいところですよね。

あるクラスを継承しているかどうか調べる

 さて、テンプレートメタプログラミングのお時間です。ここでは、あるクラスが、特定のクラスを継承しているかどうか調べるテンプレートをご紹介します。結論から言うと、以下のコードで判別できます。

template<class Base, class Sub>
class IsBaseOf
{
    typedef int T;
    struct F { int i, j; };
private:
    static Sub *d;
    static T check(const Base &);
    static F check(...);
public:
    static const bool value = sizeof(check(*d)) == sizeof(T);
};

 「IsBaseOf<Base, Something>::value」で、SomethingがBaseを継承していればtrueを、そうでなければfalseが返ってきます。

 このテンプレートクラスの仕組みはとても簡単で、メンバ関数のオーバーロードを使って、Baseを継承している場合とそうでない場合のメンバ関数を作り、その戻り値の構造体のサイズを別々にしておくことで、sizeofの結果が変わることを利用しています。
 つまり、Baseを継承したクラスであれば「static T check(const Base &)」が実体化され、そうでない場合は「static F check(...)」が実体化ます。sizeof(T)と比較していますので、「static T check(const Base &)」が実体化された場合のみvalueがtrueになります。

さて、上記のテンプレートクラスで継承関係にあるかどうかはわかりました。次は、このテンプレートクラスの実体化結果を用いてメンバ関数をオーバーロードする方法を考えます。

SFINAによるメンバ関数のオーバーロード

 SFINAEなんて変な言葉が出てきました。SFINAEは「Substitution Failure Is Not An Error」の略で、ミルキィなんとか風に訳すと「置換失敗はエラー!じゃない!!」ってな感じでしょうかね。
 これが何なのかというと、テンプレート関数においてテンプレートの置き換えに失敗した場合に、コンパイルエラーではなくオーバーロード対象から外すというC++の規格によって、オーバーロード解決にうまく使うことが出来るってわけです。

 というわけで早速以下のテンプレートクラスを作ります。

template<bool B, typename T>
struct enable { typedef T type; };

template<typename T>
struct enable<false, T> { };

template<bool B, typename T>
struct disable : public enable<!B, T> { };

 とてもシンプルなテンプレートクラスです。enableクラスは、Bがtrueの時typeという型を宣言し、そうでない時は何もしません。逆にdisableはBがfalseの時typeを宣言します。
 おそらくboostのenable_ifの類もこれとほぼ同じ実装です。

 賢い皆様ならすでにお気づきのことでしょう。そう、このテンプレート引数Bに、先ほど作ったIsBaseOfクラスのvalueの値を入れるわけですね。そうすると、特定のクラスを継承した場合のみtypeが宣言される、という状況を作り上げることができます。
 そして、先ほどのSFINAEを使って、オーバーロードの解決をうまいことやります。具体的には以下のようなコードになります。

// Serializableを継承している時に実体化される
template<typename T>
void serialize(
    T &object,
    typename enable<IsBaseOf<Serializable, T>::value, T>::type* = 0
) { }

// Serializableを継承していない時に実体化される
template<typename T>
void serialize(
    T &object,
    typename disable<IsBaseOf<Serializable, T>::value, T>::type* = 0
) { }

 上の例では、enableがtype型を持っている時のみ実体化され、typeを持っているときはテンプレートの適用失敗となり、テンプレート関数が実体化されません。disableはenableの逆ですからどっちか片方は必ず実体化されることになります。
 あとはこの関数の中で明示的にSerializableクラスに対する処理を行うようにすればバッチグー(死語)です。

 ちなみにSFINAEの規格に則ってるコンパイラはそんなに多くないとか。Visual C++だと2008くらいからうまくいくらしいです。

シリアライザ全容

 以下にシリアライザの全体のコードを載せます。

#pragma once

#include <iostream>

namespace Types
{
    // 特定のクラスを継承しているか調べる
    template<class Base, class Sub>
    class IsBaseOf
    {
        typedef int T;
        struct F { int i, j; };
    private:
        static Sub *d;
        static T check(const Base &);
        static F check(...);
    public:
        static const bool value = sizeof(check(*d)) == sizeof(T);
    };

    // あとでオーバーロード解決に使用する
    template<bool B, typename T>
    struct enable { typedef T type; };

    template<typename T>
    struct enable<false, T> { };

    template<bool B, typename T>
    struct disable : public enable<!B, T> { };
}

namespace Serialization
{
    class Archive;

    // このクラスを継承すると、シリアライズ可能になる
    class Serializable
    {
    public:
        virtual void serialize(Archive &data) = 0;
    };

    // シリアライザ
    class Archive
    {
    private:
        void *data;
        bool _load;

        /* 書き込み処理 */
        template<typename T>
        inline void write(T &object) { static_cast<std::ostream *>(data)->write(reinterpret_cast<char *>(&object), sizeof(object)); }

        template<typename T>
        inline void write(T *object) { static_cast<std::ostream *>(data)->write(reinterpret_cast<char *>(object), sizeof(*object)); }

        template<>
        inline void write<Serializable>(Serializable &object) { object.serialize(*this); }

        template<>
        inline void write<Serializable>(Serializable *object) { object->serialize(*this); }

        /* 読み込み処理 */
        template<typename T>
        inline void read(T &object) { static_cast<std::istream *>(data)->read(reinterpret_cast<char *>(&object), sizeof(object)); }
        
        template<typename T>
        inline void read(T *object) { static_cast<std::istream *>(data)->read(reinterpret_cast<char *>(object), sizeof(*object)); }

        template<>
        inline void read<Serializable>(Serializable &object) { object.serialize(*this); }

        template<>
        inline void read<Serializable>(Serializable *object) { object->serialize(*this); }


        template<typename T>
        inline Archive &serialize(T &object, typename Types::enable<Types::IsBaseOf<Serializable, T>::value, T>::type* = 0) { _load ? read<Serializable>(object) : write<Serializable>(object); return *this; }

        template<typename T>
        inline Archive &serialize(T &object, typename Types::disable<Types::IsBaseOf<Serializable, T>::value, T>::type* = 0) { _load ? read(object) : write(object); return *this; }

        template<typename T>
        inline Archive &serialize(T *object, typename Types::enable<Types::IsBaseOf<Serializable, T>::value, T>::type* = 0) { _load ? read<Serializable>(object) : write<Serializable>(object); return *this; }

        template<typename T>
        inline Archive &serialize(T *object, typename Types::disable<Types::IsBaseOf<Serializable, T>::value, T>::type* = 0) { _load ? read(object) : write(object); return *this; }

    public:
        Archive(std::istream *stream) : _load(true), data(stream) { }
        Archive(std::ostream *stream) : _load(false), data(stream) { }
        Archive(std::istream &stream) : _load(true), data(&stream) { }
        Archive(std::ostream &stream) : _load(false), data(&stream) { }

        template<typename T>
        inline Archive &operator +(T &object) { return serialize(object); }

        template<typename T>
        inline Archive &operator +(T *object) { return serialize(object); }
    };
}

 長ったらしいですが、コードが汚いだけで特に難しいことはしていません。内部でストリームを持っており、それにデータを流しこんだし読み込んだりしています。コンストラクタに入力ストリーム、或いは出力ストリームを指定してArchiveクラスのインスタンスを生成すると、それ以降「+」演算子を用いてシリアライズ、デシリアライズが可能になります。
 _loadは読み込み専用なのか書き込み専用なのかを判定するためのメンバです。先述の通り、テンプレート関数の仮想化が出来ないため、実行時に読み込み書き込みのどちらの操作を行うべきなのか判定しています。

 おおまかな使い方は以下のとおりで、構造体等はまるごと保存されますが、任意のメンバのみをシリアライズしたい場合は、Serializableを継承したクラスを作ります。

class Something : public Serializable
{
private:
    int num1;
    unsigned int num2;
    short num3; // シリアライズしない
public:
    // シリアライズメンバ関数(+を使ってデータを結合)
    void serializa(Archive &data) { data + num1 + num2; }
}

 上のようにすることで、Somethingクラスのnum1とnum2がシリアライズされます。num3はシリアライズ対象にはなりません。

 また、シリアライズしたデータをファイルに保存するには、以下のようにします。

std::ofstream ofs("test.bin", std::ios_base::binary);
Serialization::Archive writer(ofs);

Something s, t;
writer + s + t;

 読み込みの時も上記とほぼ同じで、Archiveのコンストラクタに入力ストリームを渡します。

std::ifstream ifs("test.bin", std::ios_base::binary);
Serialization::Archive reader(ifs);

Something s, t;
reader + s + t;

最後に

 ちょっと遠回りな感じになりましたが、以上で超簡単なシリアライザの実装は終わりです。最初にも書いた通り、このクラスは超単純で基本的な処理しか行なっていません。具体的には、以下の様な部分の実装がありません。

  • バイトオーダーを考えていない(ネットワーク越しにデータを送るのには使えない)
  • POD型以外はシリアライズ出来ない(std::stringやstd::vector等は単純なシリアライズが出来ない)

 などが挙げられます。エラーハンドリングもロクにしていませんので、このままで使うのはちと危険かもしれません。実際のところ、最初にも書きましたがゲームのセーブデータを保存したいっていう理由で作ったので、整数さえ保存できればいいような状況でした。そんな状況で作ったものなのであんまり汎用性はないです。boost::serializationを使いましょう!

 C++ってちょっと考えればだいたいのことは出来るんだぜー、っていうのを知ってもらえたら幸いですね。

 ちなみにここに載せたコードは自由に使って構いません。というより結構メジャーなコードを組み合わせたようなものなので…。使うときは自己責任でおねがいしますね。

0 件のコメント :

コメントを投稿