C++シリアライザへの挑戦

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

1.はじめに

こんにちは。

C#にはシリアライザがあります。シリアライザを使うとクラスを一発で保存/回復でき、クラスのメンバ変数を追加したり削除したりしても保存/回復処理を修正しないで済みます。シリアライザを使えばロジックの開発に注力できるので本当にありがたいです。

C++にもboost::serializationcerealがあります。でも、C#にはどうしても届かない部分があります。クラスのメンバ変数を保存/回復するシリアライズ関数を手で書かないといけない点です。例えば、メンバ変数を追加した時シリアライズ関数も修正しないと保存/回復されないので、テストで検出できるとは言え修正漏れが なかなか痛いです。
そこで、C++でもC#のような反映漏れしにくい方法はないか考えました。そして、Clang/LLVMlibToolingと言う構文解析ライブラリに辿り着きました。これを使ってソース・コードを解析しクラスのメンバ変数リストを自動生成することで、メンバ変数を増減した時でも保存/回復コードをC#同様メンテナンスしないで済むのです。

また、保存/回復するデータはプログラムのバージョン・アップに伴い修正されていきます。それに対応できないと使いものになりません。そこで、変更対応にも注力しました。結果、全ての変更に対応できると言うわけではないですが、良く発生する変更には対処できたと思います。その点ではC#を超えることができたのではないかと思います。

かなり頑張ったので名前を付けてみました。Theolizer(セオライザ)です。
多くの方に使って頂けると嬉しいので、この場を借りて特長と使い方をご紹介させて下さい。

2.Theolizerの特長

まず、特長です。

  1. 自動シリアライズ
    クラス・メンバのリストを自動生成し、ソース・ファイルに反映します。これによりメンバ変数の追加や削除、順序変更をした時も自動的に保存/回復できるようになりました。また、この原理を応用してenum型をシンボル名でも保存できるようにしました。シンボル値が変わっても回復できます。
  2. ポインタの回復
    普通はポインタをファイルへ保存しても意味がありません。しかし、boost::serializationはオブジェクト追跡することで実質的にポインタをファイルへ保存して回復できます。本当にびっくりしました。これは凄いとboostを参考にポインタを回復できるようにしました。ポリモーフィズムにも対応しました。
  3. 保存先指定
    データを保存するファイルは1つとは限らないと思います。設定ファイルやデータ・ファイル等の異なるファイルへ1つのクラスを分けて保存したい場合もあります。それに対応しました。
  4. バージョン変更対応
    バージョンを上げる時にバージョン・アップ処理を記述できます。 Ver.1→Ver.2→Ver.3と上げたとします。Ver.1の古いデータを読んだ時、
    ・ Ver.1→Ver.2へのバージョン・アップ処理
    ・ Ver.2→Ver.3へのバージョン・アップ処理
    を順番に実行します。つまり、Ver.3へ上げた時Ver.2→Ver.3のバージョン・アップ処理のみ追加すればよいです。
    従来のシリアライザは1つバージョンを上げたら、過去のバージョン全てから現在のバージョンへバージョン・アップできるように修正する必要が有るため、寿命の長いプログラムで使うのは厳しかったと思います。

1はC#やJavaのシリアライザは対応しています。しかし、2, 3, 4の機能はありません。
2は 概ねboostの受け売りです。これにより複雑なデータ構造の保存/回復が可能になります。
3はオリジナルです。大規模なデータ構造へ適用しやすくなります。
4もオリジナルです。寿命の長いプログラムへ適用しやすくなります。

3.対応状況

C++11規格に対応したコンパイラであれば適用できることを目標に設計しました。プラットフォームに依存する部分は標準ライブラリ、boost、libToolingを使ってほぼ吸収しています。

現在のところ、Windows 10上のVisual Studio 2015とMinGW 5.4.0、および、ubuntu 16.04 LTS上のgcc 5.4.0にて、デバッグが完了し、半分くらいの自動テストの実装が終わったところです。
将来的にはOS X El Capitanにも対応したいと考えています(そのためにmac book airを買いました。初めてのmacなので少し時間が掛かりそうです。)

現在使うことができるフォーマットはJson形式と独自バイナリー形式です。
Json形式はUTF-8で文字列をエンコードします。バイナリー形式の場合は文字列のエンコードは変換しません。
フォーマットは比較的簡単に追加できるように工夫していますのでxmlへの対応も可能ですが、手が回っていません。

4.使い方

サンプル・ソースを使って、自動シリアライズとポリモーフィズムについて使い方を解説します。
ポインタの回復(オブジェクト追跡)、保存先指定、バージョン変更についてはここで解説するにはちょっと長いのでこちらの技術解説ブログを参照下さい。

4-1.クラス定義main.h

ポリモーフィズムの使い方を示すため、基底クラス1つ(Base)と派生クラスを2つ(Derived0, Derived1)定義しています。保存/回復のためのコードは今回のサンプル・ソースでは下記の3つです。

  • theolizer/で始まるヘッダのインクルード
  • THEOLIZER_で始まるマクロ
  • デフォルト・コンストラクタ
    トップ・レベル(後述のTHEOLIZER_PROCESSで直接指定するもの)を除き、デフォルト・コンストラクタが必要となります。 (他のクラスのメンバ変数や基底クラス、ポインタの先で回復するクラスで必要です。)
//############################################################################
//      Theolizer解説用サンプル・プログラム
//############################################################################

#if !defined(MAIN_H)
#define MAIN_H

// ***************************************************************************
//      インクルード
// ***************************************************************************

// 標準ライブラリ
#include <iostream>
#include <string>

// Theolizerライブラリ
#include <theolizer/serializer.h>

// ***************************************************************************
//      クラス定義
// ***************************************************************************

//----------------------------------------------------------------------------
//      enum型
//----------------------------------------------------------------------------

enum Type
{
    etBase,
    etDerived0,
    etDerived1
};

//----------------------------------------------------------------------------
//      基底クラス
//          Version.1なのでTHEOLIZER_INTRUSIVE()省略可能
//----------------------------------------------------------------------------

struct Base
{
    Type            mType;

    Base() : mType(etBase) { }
    Base(Type iType) : mType(iType) { }

    virtual void print()
    {
        std::cout << "Base()\n";
    }
};

//----------------------------------------------------------------------------
//      派生クラス0
//          ポモリーフィズム対応のためTHEOLIZER_INTRUSIVE()省略不可
//----------------------------------------------------------------------------

class Derived0 : public Base
{
    std::string     mString;
public:
    Derived0() : Base(etDerived0) { }
    Derived0(char const* iString) : Base(etDerived0), mString(iString) { }

    virtual void print()
    {
        std::cout << "Derived0(" << mString << ")\n";
    }
    THEOLIZER_INTRUSIVE(CS, (Derived0), 1);
};

// ポリモーフィズム対応
THEOLIZER_REGISTER_CLASS((Derived0));

//----------------------------------------------------------------------------
//      派生クラス1
//          ポモリーフィズム対応のためTHEOLIZER_INTRUSIVE()省略不可
//----------------------------------------------------------------------------

class Derived1 : public Base
{
    std::wstring    mWstring;
public:
    Derived1() : Base(etDerived1) { }
    Derived1(wchar_t const* iWstring) : Base(etDerived1), mWstring(iWstring) { }

    virtual void print()
    {
        theolizer::u8string string=mWstring;
        std::cout << "Derived1(" << string.str() << ")\n";
    }
    THEOLIZER_INTRUSIVE(CS, (Derived1), 1);
};

// ポリモーフィズム対応
THEOLIZER_REGISTER_CLASS((Derived1));

#endif

Theolizerは保存/回復するクラスを下記3種類に分類して対応しています。

  1. 非侵入型完全自動
    クラス定義に対してシリアライズ指定を行わずに、保存/回復するクラスです。
    上記ソースでは、enum型のTypeと基底クラスのBaseが該当します。
    publicとprotectedなメンバ変数を自動的に保存/回復します。
  2. 侵入型半自動
    クラス定義内部でシリアライズ指定したクラスです。
    上記ソースでは、派生クラスのDerived0とDerived1が該当します。
    privateも含めて全てのメンバ変数と基底クラスを自動的に保存/回復します。
  3. 非侵入型手動
    クラス定義の外側でシリアライズ指定したクラスです。シリアライズ関数を手で記述するものです。主にSTLをシリアライズ対応するために用意しました。使い方が難しいため、現在はTheolizer専用の位置づけとしています。
    後述のmain.cppで使っているstd::list<>とstd::unique_ptr<>が該当します。

サンプル・ソースのBaseクラスは保存/回復コードが実体化されるため、シリアライズ指定を省略できます。Derived0, Derived1クラスはポリモーフィズムによりBaseクラスを経由して保存/回復されるため保存/回復コードが実体化されません。そこで、シリアライズ対象であることをTheolizerに教えるため、THEOLIZER_INTRUSIVE()により侵入型半自動としています。

THEOLIZER_INTRUSIVE()マクロのパラメータ
CSはメンバ変数をデフォルトで保存することを指定します。保存しないと指定したメンバ変数は保存されません。
CSの代わりにCNと書くとデフォルトでは保存しないことを指定します。保存すると指定したメンバ変数のみ保存されます。

次にクラス名を()で囲んで記述します。(メソッドの外で自クラスにアクセスできれば不要なのですが、
その方法を見つけることができませんでした。)

最後はバージョン番号です。1から初めて1づつ上げていきます。

また、これらの派生クラスはポリモーフィズム対応であることを指定するために、THEOLIZER_REGISTER_CLASS()マクロを使います。

このように保存/回復用にメンバ変数のリストを記述していませんが、保存/回復できます。

u8stringについて
theolizer::u8stringと言う型が出てきています。これはUTF-8でエンコードされているstd::stringです。
Shift-JIS(Windowsのみ)、UTF-8/UTF-16/UTF-32間の文字コード変換を担っています。

ついでに、これを用いてcin, cout, cerrとUnicode文字列でやり取りしてもWindowsのコマンド・
プロンプトで文字化けしない仕組みも実装してます。
> std::cout << u8"これはUTF-8です。\n";
> std::cout << L"これはUTF-16です。\n";
などしても文字化け無く表示されます。デバッグ用に便利です。

なので、実は①の代わりに②のように書いてもコンパイルできますし、普通の使い方なら文字化けしません。
 ① std::cout << "Derived1(" << string.str() << ")\n";
 ② std::cout << "Derived1(" << mWstring << ")\n";
(mWstringはstd::wstring型ですので標準ではコンパイル・エラーになります。)

4-2.実装部main.cpp

Base, Derived0, Derived1のインスタンスをstd::listで管理しました。
そして、std::unique_ptrを用いて安全に保持しています。

//############################################################################
//      Theolizer解説用サンプル・プログラム
//############################################################################

// ***************************************************************************
//      インクルード
// ***************************************************************************

// 標準ライブラリ
#include <fstream>

// Theolizerライブラリ
#include <theolizer/serializer_json.h>
#include <theolizer/list.h>
#include <theolizer/memory.h>

// 構造体定義
#include "main.h"

// Theolizer自動生成先
#include "main.cpp.theolizer.hpp"

// ***************************************************************************
//      メイン
// ***************************************************************************

int main(int argc, char* argv[])
{
    try
    {

//----------------------------------------------------------------------------
//      データ設定
//----------------------------------------------------------------------------

        std::list<std::unique_ptr<Base>>    aList;
        aList.emplace_back(new Base());
        aList.emplace_back(new Derived0(u8"派生クラス0"));
        aList.emplace_back(new Derived1( L"派生クラス1"));

//----------------------------------------------------------------------------
//      保存
//----------------------------------------------------------------------------

        // 保存処理
        {
            std::ofstream   aStream("test.log");
            theolizer::JsonOSerializer<>  js(aStream);
            THEOLIZER_PROCESS(js, aList);
            js.clearTracking();
        }

//----------------------------------------------------------------------------
//      回復
//----------------------------------------------------------------------------

        aList.clear();

        // 回復処理
        {
            std::ifstream   aStream("test.log");
            theolizer::JsonISerializer<>  js(aStream);
            THEOLIZER_PROCESS(js, aList);
            js.clearTracking();
        }

//----------------------------------------------------------------------------
//      結果表示
//----------------------------------------------------------------------------

        for (auto&& item : aList)
        {
            item->print();
        }
return 0;
    }
    catch (theolizer::ErrorInfo &e)
    {
        std::cout << e.getMessage();
    }
    return 1;
}

重要な部分は、#include "main.cpp.theolizer.hpp"です。
このインクルードされているファイルにTheolizerが自動的にソース・コードを生成します。
保存/回復するクラスやenum型の定義と、下記THEOLIZER_PROCESS()マクロの間で#includeします。

そして、保存/回復自体はTHEOLIZER_PROCESS()マクロで行います。
今回は、aListを保存したり、回復したりしています。(保存も回復も同じマクロを用います。)
aListは他のクラスに含まれていても対応できます。これにより、結構複雑なデータ構造でも一発で保存/回復できます。
また、THEOLIZER_PROCESS()を複数記述することもできます。

js.clearTracking();はオブジェクト追跡の区切りに置きます。この時点で未解決なオブジェクトが残っていたらエラーになります。
Theolizerがエラーを検出したら、デフォルトではtheolizer::ErrorInfoを投げます。

4-3.自動生成ソースmain.cpp.theolizer.hpp

Gistにおいています。
解説すると長くなりすぎるので省略しますが、マクロを定義してそれを展開するファイルを#includeしています。
最も重要なマクロは下記です。自動生成ソースは自動的に処理されますので、普段は気にする必要ありません。

マクロ 内容
THEOLIZER_GENERATED_ENUM_LIST() enum型のシンボル・リスト
THEOLIZER_GENERATED_BASE_LIST() クラスの基底クラス・リスト
THEOLIZER_GENERATED_ELEMENT_LIST() クラスのメンバ変数リスト

4-4.実行結果

実行すると下記のJsonファイルが生成されます。
少し[]のネストが深いです。クラス1つに対して下記のように出力するため、ネストが深くなってしまいます。

  • [ ]
    メンバ変数を定義順で出力する。
  • { }
    メンバ変数を変数名と対応して出力する。
  • [ { } ]、[ [ ] ]
    オブジェクト追跡する時に使います。 これはポリモーフィズムによるデータ回復のため、ポイント先のクラス名も記録します。
{
    "SerialzierName":"JsonTheolizer",
    "GlobalVersionNo":1,
    "TypeInfoList":[1]
}
[
    3,
    [
        [1,"Base",{
            "mType":"etBase"
        }]
    ],
    [
        [2,"Derived0",{
            "(Base)":{
                "mType":"etDerived0"
            },
            "mString":"派生クラス0"
        }]
    ],
    [
        [3,"Derived1",{
            "(Base)":{
                "mType":"etDerived1"
            },
            "mWstring":"派生クラス1"
        }]
    ]
]

頭の5行はヘッダです。Theolizerが出力したことやバージョン番号等を記録しています。
各クラスのメンバ変数は"メンバ変数名":値の書式で記録されています。
mTypeはenum型です。値ではなくシンボル名で保存しています。
メンバ変数名の位置に"(Base)"があります。これは基底クラスのBaseを示してます。

Windowsのコマンド・プロンプトには次のように出力されます。

>Test.exe
Base()
Derived0(派生クラス0)
Derived1(派生クラス1)

5.ビルドについて

以上のようにシリアライズに必要なソースを自動生成します。それを通常のビルドと異なる手順で処理するのは手間がかかります。また、インクルード・パス等、通常のコンパイル・パラメータの一部をTheolizerは必要としますが、それをまた別途指定するのも手間がかかります。
そこで、ソースを自動生成するTheolizerドライバをビルド・システムとコンパイラの間に割り込ませるようにしました。

そのためにコンパイラ(cl.exeやg++.exe)をリネームして、元の名前でTheolizerドライバをコピーします。
これによりビルド・システムはTheolizerドライバをビルドに必要なパラメータと共に起動します。
そして、Theolizerドライバは特定のコマンドライン・パラメータ(THEOLIZER_ANALYZEマクロの定義)を指定されない限り、元のコンパイラへパススルーします。これで通常のビルドへ影響することなく、必要な時だけソース自動生成機能を働かせることができます。
現在のところ、これはスムーズに機能しています。Visual Studioからも通常通りに使えます。CMakeはConfigureする時にコンパルしますがその時も悪さしません。

Clang/LLVMのコンパイル・ドライバは他のコンパイラを置き換える機能を持っています。以上の機能は、そのソースを参考にして実装してます。

6.開発の経緯

実は、C#で最初にシリアライザに出会った時、これは使える!と思ったのですが、残念なことに自由度が足りず、当時担当していたプロジェクトでは使えませんでした。しかし、C#にはリフレクションがあり、クラスのメンバ・リストを得ることができます。これを使ってそのプロジェクト用にシリアライザを意外に素早く開発できました。クラスの追加/変更が頻繁に発生しましたが、シリアライザのお陰で保存/回復処理をほとんど触らなくて済み、大幅に工数削減できました。クラス型のメンバ変数を追加することもできますので、クラスを丸ごと追加しても保存/回復処理を触らないで良いって素晴らしいです。

昔からシリアライザのようなものを開発することが可能な筈との思いがありましたが、C#でできることが判り、ならばC++でもできないか?とトライを始めたのが約2年前です。
まずは、boost::serializationの方法を検討しました。これはテンプレート・メタ・プログラミング(TMP)を使ってシリアライズしています。TMPは静的に型処理するのでリフレクションに近いこと(メンバ変数の有無確認など)ができ、これを使えばC#のようなシリアライザを開発できそうな気がして猛勉強しました。しかし、残念ながらメンバ変数リストを取り出せず無理でした。(今にして思えば当たり前なのですが、当時はTMPの理解が不足してました。)

次に、構文解析してクラス定義情報を獲得し、ソースを自動生成できないか検討しました。自力解析は早々に断念して構文解析ライブラリを探し、幾つか見つかりました。その中でもApple社も採用しているC++コンパイラClang/LLVMの一部という点で安心感もあり、更新も精力的に行われているのでlibToolingを使うことにしました。これはさすが素晴らしいです。お陰で確実な構文解析ができ、Theolizerを動かし始めることに成功しました。

7.最後に

以上はTheolizerの一部ですが、これを使うことでC++データの保存と回復プログラムの開発工数を削減できる可能性をお伝えできたのではないかと思います。

11月5日よりオープンβを開始しました。どなたでもご自由にお試しできます。
GitHubで公開してますので、興味を持たれた方は、是非お試し下さい。