ObjectSquare [2009 年 9 月号]

[技術講座]


2 boost::serializationの利用法


株式会社 オージス総研
組み込みソリューション部
近藤 貴俊

目次

0. はじめに
1. シリアライズとは?
2. Contentのシリアライズ
3. 親クラス経由のContentのシリアライズ
4. 動作確認

 0. はじめに

前回の記事ではweak_ptrを利用するケースについて紹介しましたが、読者の方から、そのアプローチがboost::enable_shared_from_thisとして準備されているんだから、あわせてそれを紹介すべきでは?とのご指摘をいただきました。そこで今回、コラムとして取り上げましたので、あわせてご覧下さい。

では、今回のテーマ、シリアライズについて紹介したいと思います。

 1.シリアライズとは?

シリアライズとは、メモリ上に存在するオブジェクトを、バイト列に変換する処理のことです。このバイト列のイメージが、1列に並んでいることから、シリアル(直列)化、シリアライズという名称で呼ばれています。シリアライズは、オブジェクトをファイルなどに保存したり、ネットワーク送信したりする際によく利用されます。一方、ファイルから読み出したバイト列からオブジェクトを復元する処理は、デシリアライズと呼ばれます。なお、シリアライズは広義では、シリアライズおよびデシリアライズの総称としても用いられます。

プログラミング言語C++では、標準ライブラリとして、iostreamといったストリーム入出力ライブラリが用意されています。このストリームライブラリによって、バイト列の入出力が可能となります。しかし、オブジェクトをバイト列に変換するシリアライズライブラリは標準では提供されていません。Boostライブラリでは、boost::serializationというシリアライズライブラリが提供されています。今回は、これを利用します。

さて、今回筆者が利用したboost::serializationのとてもシンプルな利用イメージを以下に示します。

#include <cassert>
#include <fstream>
#include <boost/serialization/serialization.hpp>
#include <boost/archive/xml_oarchive.hpp>
#include <boost/archive/xml_iarchive.hpp>
#include <boost/serialization/nvp.hpp>

class MyClass {
public:
    MyClass():data1_(0) {}
    void setData1(int val)
    { 
        data1_ = val;
    }
    int getData1() const 
    { 
        return data1_;
    }
private:
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */) 
    {
        ar & BOOST_SERIALIZATION_NVP(data1_);
    }
    int data1_;
};

int main()
{
    {
        // serialize
        MyClass m;
        m.setData1(123);
        std::ofstream ofs("output.xml");
        assert(ofs);
        boost::archive::xml_oarchive oa(ofs);
        oa << BOOST_SERIALIZATION_NVP(m);
    }
    {
        // de-serialize
        std::ifstream ifs("output.xml");
        assert(ifs);
        boost::archive::xml_iarchive ia(ifs);
        MyClass m;
        ia >> BOOST_SERIALIZATION_NVP(m);
        // check
        assert(m.getData1() == 123);
    }
}

このコードをコンパイルするためには、boostライブラリのパスをインクルードパスに指定する必要があります。さらに、リンクするライブラリとして、boost::serializationライブラリを指定する必要があります。スマートポインタをはじめとする多くのBoostライブラリは、ヘッダファイルのインクルードのみで利用可能ですが、boost::serializationライブラリを利用するためには、Boostライブラリのビルドが必要です。ビルド手順は、Windowsの場合https://www.boost.org/doc/libs/1_39_0/more/getting_started/windows.htmlを、LinuxやMacOSなどUnix系OSの場合はhttps://www.boost.org/doc/libs/1_39_0/more/getting_started/unix-variants.htmlに詳細が記述されています。いずれにしても、bjamをいうツールを利用します。ビルドすると、stage/libディレクトリの下に、libboost_serialization-gcc43-mt.aといったファイルができているはずなので、これをリンクするファイルとして指定することになります(インストールまで行った場合は、インストール先のライブラリディレクトリとなります)。なお、ライブラリファイル名は、OSやコンパイラによって若干異なります。

さて、プログラムのコンパイル・リンクができたら実行してみましょう。実行するとoutput.xmlが生成され、その中身は以下のようになっているはずです。

<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<!DOCTYPE boost_serialization>
<boost_serialization signature="serialization::archive" version="5">
<m class_id="0" tracking_level="0" version="0">
    <data1_>123</data1_>
</m>
</boost_serialization>

インクルードしているヘッダファイルや、利用している関数や演算子の詳細は、https://www.boost.org/doc/libs/1_39_0/libs/serialization/doc/index.htmlに詳細にドキュメント化されていますので、そちらを参照してください。ここでは、簡単にポイントを絞って説明します。

まず、シリアライズしたいクラスには、serializeメンバ関数テンプレートを用意します。そして、その定義の中で、ArchiveオブジェクトとBOOST_SERIALIZATION_NVPマクロを&で結ぶ形で記述します。マクロ引数には、シリアライズしたいメンバ変数を指定します。

    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */) 
    {
        ar & BOOST_SERIALIZATION_NVP(data1_);
    }

Archive型の引数arには、シリアライズ時にはシリアライズ先となるオブジェクトが、デシリアライズ時にはデシリアライズ元となるオブジェクトが渡ってきます。今回の例では、main関数で作成したストリームがこれにあたります。オペレータ&は、その右辺値をシリアライズ時はArichiveにシリアライズし、デシリアライズ時にはArichiveからデシリアライズします。この仕組みについての詳細は、https://www.boost.org/doc/libs/1_39_0/libs/serialization/doc/serialization.html#splittingを参照していただくとして、ここでは、シリアライズ対象を右辺に渡せば、シリアライズ・デシリアライズをうまくやってくれるという認識でOKです。最後のマクロ、BOOST_SERIALIZATION_NVPは、変数名と変数の値を対応付けでシリアライズするための仕組みです。output.xmlの、<data1_>123</data1_>の部分を見ると分かりますが、XMLの開始タグと終了タグが変数名となっており、その間に変数の値が挟まれます。

なお、このようなタグはXML形式でシリアライズを行う場合のみ付加されます。boost::serializationでは、XML形式の他に、テキスト形式、バイナリ形式のシリアライズをサポートしています。可読性を重視する場合やデバッグ時はXML形式、効率を重視する場合はバイナリ形式といった形で使い分けると良いでしょう。テキスト形式やバイナリ形式でシリアライズする際、BOOST_SERIALIZATION_NVPが指定されていても、無視されるだけなので悪影響はありません。data1_を直接オペレータ&に渡したのと同じ振る舞いになります。

 2.Contentのシリアライズ

さて、基本的なシリアライズの仕組みが分かったところで、前回取り上げたクラスContentをシリアライズしてみましょう。

Content(クラス図)

注:前回の記事では、クラスClientを一般的な利用者という意味で用いており、コードではmain関数がこれに対応する形となっていました。しかし、今回はクラスClientがシリアライズ対象の具体的なクラスとして存在することになります。そこで、クラスClientがクラスContentを生成するのではなく、外部(main関数など)で生成されたContentのshared_ptrを、Clientに追加する、addContentという操作を設けています。

クラスContentは、クラスClientに所有されるため、クラスClientをシリアライズすれば全Contentをシリアライズすることができます。

シリアライズを行うコードはsrz_content.cppとなります。順次内容を見ていきましょう。

まず、シリアライズに必要なヘッダファイルをインクルードします。 boost::shared_ptrやboost::weak_ptrのシリアライズを行うために、以下のヘッダファイルをインクルードしています。

#include <boost/serialization/shared_ptr.hpp>
#include <boost/serialization/weak_ptr.hpp>

同様に、std::listをシリアライズするために、以下のヘッダファイルをインクルードしています。

#include <boost/serialization/list.hpp>

このように、STLやC++標準ライブラリ、Boostライブラリによって提供されるクラスの多くは、serializationライブラリによってあらかじめサポートされています。サポートされていないクラスに関しては自前でserialization処理を記述することで、シリアライズ可能になります。

クラスClientは、今回シリアライズ対象とするクラスです。メンバとして、クラスContentのshared_ptrのリストを持っています。

class Client {
public:
    void addContent(const ContentSp& content)
    {
        colContent_.push_back(content);
    }
    const std::list<ContentSp> &getContentsList() const { return colContent_; }
private:
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */) 
    {
        ar & BOOST_SERIALIZATION_NVP(colContent_);
    }
    std::list<ContentSp> colContent_;
};

コメント // serializeの部分が、シリアライズのために追加したコードです。これは、前述のMyClassの例と全く同じです。この一連のコードを追加するだけで、簡単にクラスの情報をシリアライズすることが可能となります。

次に、Clientが保持するContentクラスのシリアライズ関連コードを示します。

class Content:public DownloadObject {
public:
    static ContentSp create(std::string uri, int priority) {
        ContentSp s(new Content(uri, priority));
        s->setWp(s);
        return s;
    }
    virtual ~Content() { std::cout << "Destroyed" << std::endl; }
    virtual void downloadCompleted() {
        std::cout << "downloadCompleted" << std::endl;
        Cache::getInstance().addContent(self_.lock());
    }
private:
    Content(std::string uri, int priority)
        :DownloadObject(uri, priority)
        { std::cout << "Created" << std::endl; }
    void setWp(const ContentSp &sp) { self_ = sp; }
    ContentWp self_;
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */) 
    {
        ar & BOOST_SERIALIZATION_NVP(self_);
    }
    
    template<class Archive>
    friend
    void save_construct_data(Archive & ar, 
        const Content *p, const unsigned int /* file_version */);
    template<class Archive>
    friend
    void load_construct_data(Archive & ar, 
        Content *p, const unsigned int /* file_version */);

};

このケースではシリアライズのために、今までとは少し異なるアプローチが必要となります。クラスContentはデフォルトコンストラクタを持っていません。実は、これまでに紹介したメンバ関数テンプレートserializeをシリアライズ対象クラスに定義するだけのアプローチでは、シリアライズ対象クラスにデフォルトコンストラクタが用意されている必要があります。デフォルトコンストラクタのないクラスをserializationライブラリを使ってシリアライズするためには、新たにsave_construct_dataおよびload_construct_dataという2つの関数を用いる必要があります。それでは、これらの関数定義を見てみましょう。

template<class Archive>
void save_construct_data(Archive & ar, const Content *p, 
    const unsigned int /* file_version */)
{
    ar & boost::serialization::make_nvp("uri", p->getUri());
    int pri = p->getPriority();
    ar & boost::serialization::make_nvp("priority", pri);
}

template<class Archive>
void load_construct_data(Archive & ar, Content *p, 
    const unsigned int /* file_version */)
{
    std::string uri;
    int pri;
    ar & boost::serialization::make_nvp("uri", uri);
    ar & boost::serialization::make_nvp("priority", pri);
    ::new(p) Content(uri, pri);
}

save_construct_dataは、シリアライズ時に呼び出される関数です。クラスをコンストラクトするのに必要な情報を取り出して、Archiveオブジェクトに対して&演算子で渡すことでシリアライズしています。

一方、load_construct_dataは、デシリアライズ時に呼び出される関数です。save_construct_dataでシリアライズした情報を順に読み込み、これらをコンストラクタの引数として与えることで、デシリアライズを実現します。引数pには、メモリは確保されているがコンストラクトされていない状態のContentオブジェクトが渡されています。これに対して、placement new構文を用いて、オブジェクトをコンストラクトしています。

このように、serializationライブラリでは、デフォルトコンストラクタが提供されないクラスのシリアライズも実現することができます。さらに、今回のケースでは、コンストラクタのアクセス制限がprivateとなっています。そこで、クラスContentの定義内で、save_construct_dataおよびload_construct_dataをフレンド関数として宣言しています。

int main()
{
    std::cout << "Test started" << std::endl;
    {
        // serialize
        Client cli;
        cli.addContent(Content::create("hoge", 1));
        cli.addContent(Content::create("fuga", 2));
        cli.addContent(Content::create("pnyo", 3));
        std::ofstream ofs("output2.xml");
        assert(ofs);
        boost::archive::xml_oarchive oa(ofs);
        oa << boost::serialization::make_nvp("cli", cli);
    }
    {
        // de-serialize
        std::ifstream ifs("output2.xml");
        assert(ifs);
        boost::archive::xml_iarchive ia(ifs);
        Client cli;
        ia >> boost::serialization::make_nvp("cli", cli);
        // check
        const std::list<ContentSp> &col = cli.getContentsList();
        std::list<ContentSp>::const_iterator i = col.begin();
        
        assert((*i)->getUri() == "hoge");
        assert((*i)->getPriority() == 1);
        ++i;
        assert((*i)->getUri() == "fuga");
        assert((*i)->getPriority() == 2);
        ++i;
        assert((*i)->getUri() == "pnyo");
        assert((*i)->getPriority() == 3);
        ++i;
        assert(i == col.end());
    }
    std::cout << "Test finished" << std::endl;
}

上記コードで、シリアライズとデシリアライズが正しく行われていることを確認することができます。

 3. 親クラス経由のContentのシリアライズ

さて、次に、クラスContentを抽象化するケースを考えてみましょう。例えば、ダウンロードして取得するContent以外に、ROMなどに最初から埋め込まれているようなContentも扱いたいケースを考えます。この場合、抽象クラスAbstractContentを導入し、この集合をクラスClientが保持します。そして、ダウンロードして取得するContentは、今までのContentという名前から、DownloadContentという名前に変更し、AbstractContentを継承します。同様に、埋め込まれているContentを例えばFixedContentという名前とし、AbstractContentを継承します。このように変更を加えると、クラス図は以下のようになります。

抽象Contentクラスを導入(クラス図)

ここで、DownloadContentに着目してください。DownloadContentは、AbstractContentとDownloadObjectの両方を継承しています。このような多重継承は、C++では珍しいことではありません。この例のように、サードパーティのライブラリがサブクラス化前提のインターフェースとなっており、そこにアプリケーション固有の抽象クラス階層を導入しようとした際には、よく現れるパターンです。

このようなケースで、Clientのシリアライズを考えてみましょう。Clientは抽象クラスであるAbstractContentの集合を保持するわけですから、シリアライズする対象も、AbstractContentとなります。しかし、実際に保存、復元しなければならないのは、DownloadContentやFixedContentです。以降、AbstractContentとDownloadContentに着目して話を進めます(FixedContentにも、DownloadContentと同じことが当てはまります)。

このように、抽象クラス経由で具象クラスをシリアライズする必要がある場合、serializationライブラリで提供されるBOOST_CLASS_EXPORTというマクロを使用します。マクロは引数を1つとる形式となっており、ここに、抽象クラス経由でシリアライズする具象クラス名を設定します。この例では、以下のようになります。

BOOST_CLASS_EXPORT(DownloadContent)

これを踏まえて、抽象クラス経由のシリアライズコードを見ていきましょう。なお、コード全体は、srz_abscontent.cppとなります。

class AbstractContent;
typedef boost::shared_ptr<AbstractContent> AbstractContentSp;

class AbstractContent {
public:
    virtual ~AbstractContent() {}
    void render() { renderImp(); }
private:
    virtual void renderImp() = 0;
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &/*ar*/, const unsigned int /* file_version */) 
    {
    }
};

上記がクラスAbstractContentの定義です。このクラスを経由して派生クラスのシリアライズを行うため、このクラスにシリアライズ対象のデータが無くても、serializeメンバ関数を定義する必要があります。余談ですが、このとき、仮引数をCスタイルでコメントアウトするテクニックがBoostライブラリのコードなどでよく使われています。この場合は、arとfile_versionに対して適用しています。これは、Unused Parameterといった、引数を利用していない旨を知らせる警告を抑制するためのテクニックです。しかし、仮引数を無くしてしまうと、その引数の意味がコードの読み手に伝わりません。そこで、引数をコメント内に書いているわけです。

メンバ関数renderは、このクラスの利用者がレンダリングを行うために公開されているインターフェースです。純粋仮想メンバ関数renderImpは、このクラスを継承するクラスにて、適切なレンダリングの実装を要求します。renderImpのアクセス制限がprivateとなっていますが、継承したクラスにてオーバーライドすることは可能です。このような実装はNVIパターンなどと呼ばれます。ここでは触れませんが、NVIパターンを適用する意味やメリットなどは、Web上でいろいろまとめられているので、興味のある方は調べてみてください。

class Cache;
typedef boost::shared_ptr<Cache> CacheSp;

class Cache {
public:
    static Cache &getInstance() { 
        static CacheSp cache_;
        if (!cache_) {
            cache_.reset(new Cache);
        }
        return *cache_.get();
    }
    void addContent(const AbstractContentSp& c) {
        contents_.push_back(c);
    }
private:
    std::vector<AbstractContentSp> contents_;
};

次にクラスCacheですが、保持するcontents_の型が、std::vector<AbstractContentSp>になったのと、メンバ関数addContentの引数が、AbstractContentSpのconst参照になっただけで、本質的な変更はありません。

次に、今回の主要な変更部分であるクラスDownloadContentを見ていきましょう。

class DownloadContent;
typedef boost::shared_ptr<DownloadContent> DownloadContentSp;
typedef boost::weak_ptr<DownloadContent> DownloadContentWp;

class DownloadContent:public DownloadObject, public AbstractContent {
public:
    static DownloadContentSp create(std::string uri, int priority) {
        DownloadContentSp s(new DownloadContent(uri, priority));
        s->setWp(s);
        return s;
    }
    virtual ~DownloadContent() { std::cout << "Destroyed" << std::endl; }
    virtual void downloadCompleted() {
        std::cout << "downloadCompleted" << std::endl;
        render();
        Cache::getInstance().addContent(self_.lock());
    }
private:
    DownloadContent(std::string uri, int priority)
        :DownloadObject(uri, priority)
        { std::cout << "Created" << std::endl; }
    void setWp(const DownloadContentSp &sp) { self_ = sp; }
    virtual void renderImp() { 
        std::cout << 
        "render URI:" << getUri() << 
        " Priority:" << getPriority() << 
        std::endl; 
    }

    DownloadContentWp self_;
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */) 
    {
        ar & BOOST_SERIALIZATION_BASE_OBJECT_NVP(AbstractContent);
        ar & BOOST_SERIALIZATION_NVP(self_);
    }
    
    template<class Archive>
    friend
    void save_construct_data(Archive & ar, const DownloadContent *p, 
        const unsigned int /* file_version */);
    template<class Archive>
    friend
    void load_construct_data(Archive & ar, DownloadContent *p, 
        const unsigned int /* file_version */);

};

BOOST_CLASS_EXPORT(DownloadContent)

まず、クラスAbstractContentを継承するように変更しました。これによって、クラスDownloadContentは、クラスDownloadObjectと、クラスAbstractContentの2つのクラスを多重継承するようになりました。次に、メンバ関数renderImpをオーバーライドする形で実装しています。ここでは、URIとPriorityを標準出力に出力するようにしてみました。後ほど、動作確認時にこの出力結果を利用します。メンバ関数serializeには、BOOST_SERIALIZATION_BASE_OBJECT_NVP(AbstractContent)を追記します。これにより、自分の親クラスをserializationライブラリに教えます。親クラスにシリアライズすべきメンバが無い場合でも、親クラス経由でシリアライズする場合はこのように記述する必要があります。そして、最後に、BOOST_CLASS_EXPORTを記述することで、このクラスは、親クラス経由でシリアライズすることができるようになります。なお、BOOST_CLASS_EXPORTを記述したとしても、(親クラスを経由せず)直接シリアライズすることは可能です。

なお、フレンド関数save_construct_dataとload_construct_dataの定義には変更はありません。

class Client {
public:
    void addContent(const AbstractContentSp& content)
    {
        colContent_.push_back(content);
    }
    const std::list<AbstractContentSp> &getContentsList() const { return colContent_; }
private:
    // serialize
    friend class boost::serialization::access;
    template<class Archive>
    void serialize(Archive &ar, const unsigned int /* file_version */) 
    {
        ar & BOOST_SERIALIZATION_NVP(colContent_);
    }
    std::list<AbstractContentSp> colContent_;
};

最後に、クラスClientもクラスCache同様、クラスAbstractContentを扱うように修正しました。以上で、親クラス経由のシリアライズができるようになりました。

以下は動作確認用コードです。

int main()
{
    std::cout << "Test started" << std::endl;
    {
        // serialize
        Client cli;
        cli.addContent(DownloadContent::create("hoge", 1));
        cli.addContent(DownloadContent::create("fuga", 2));
        cli.addContent(DownloadContent::create("pnyo", 3));
        std::ofstream ofs("output2.xml");
        assert(ofs);
        boost::archive::xml_oarchive oa(ofs);
        oa << boost::serialization::make_nvp("cli", cli);
    }
    {
        // de-serialize
        std::ifstream ifs("output2.xml");
        assert(ifs);
        boost::archive::xml_iarchive ia(ifs);
        Client cli;
        ia >> boost::serialization::make_nvp("cli", cli);
        // check
        const std::list<AbstractContentSp> &col = cli.getContentsList();
        std::list<AbstractContentSp>::const_iterator i = col.begin();
        
        (*i)->render();
        ++i;
        (*i)->render();
        ++i;
        (*i)->render();
        ++i;
        assert(i == col.end());
    }
    std::cout << "Test finished" << std::endl;
}

 4. 動作確認

早速動作確認してみましょう。デシリアライズされたAbstractContentオブジェクトのrender()を呼び出すことで、シリアライズ時のURIとPriorityが表示されるはずです。

Test started
Created
Created
Created
Destroyed
Destroyed
Destroyed
Created
Created
Created
Destroyed
Destroyed
Destroyed
セグメンテーション違反です

実行すると、期待通りに動作せず、私の環境(Linux 2.6.24-gentoo-r5 #1 SMP)では、上記のようなメッセージを出力し、プログラムが異常終了してしまいました。

注:上記現象はBoostライブラリのVersion 1.39.0にて発生します。本記事執筆中に、筆者の修正パッチが適用されたBoostライブラリのVersion 1.40.0がリリースされました。よって、Version 1.40.0では上記現象は発生しません。

このようなときは、もちろん自らのコードを疑うのですが、何か解析の糸口がつかめないかと、コードにいくつかの変更を加えては確認するというアプローチを私は採ります。例えば、以下のように、多重継承の順序を変更してみました。

変更前

class DownloadContent:public DownloadObject, public AbstractContent {

変更後

class DownloadContent:public AbstractContent, public DownloadObject {

すると、プログラムは、期待通りの動作をしたのです。

Test started
Created
Created
Created
Destroyed
Destroyed
Destroyed
Created
Created
Created
render URI:hoge Priority:1
render URI:fuga Priority:2
render URI:pnyo Priority:3
Destroyed
Destroyed
Destroyed
Test finished

まだ、この段階ではたまたま動いている(ように見える)だけかもしれません。そもそも、複数の親クラス経由でそれぞれシリアライズしたい場合に、解決法がありません。

そんなわけで、Boostライブラリのドキュメントを再度熟読し、「親クラス経由でシリアライズを行いたいクラスは、クラス定義時に、その親クラスを1番目に継承しなければならない」といった制限事項があるか調査しました。調査の結果、そのような制限事項はありませんでした。

そこで、これはもしかしたら、Boost serializationライブラリのバグかもしれない、と考えるようになり、勇気を出して(笑)バグ報告を行うことになるのですが、その内容はまた次回に。


© 2009 OGIS-RI Co., Ltd.
Prev Index Next
Prev Index Next