Edited at

C++のcerealのシリアライズが快適すぎるやばい

More than 1 year has passed since last update.


cerealって?

cerealとは、C++11用のjson, xml, binaryシリアライズライブラリです。似たようなものにboost::serializationや、googleのprotocol buffersなどがあります(ほかにもまだありそうですが)。

ではなぜcerealなのか?

cerealには他の二つにはない利点として、


  • ヘッダーオンリー

  • ライブラリが小規模

というのがあります。

この性質は移植作業を大変楽にしてくれます。

というのも自分、boost::serializationをしばらく使っていたのですが、iOSでも使おうとしてビルドで問題がおきて、もっと良いシリアライズライブラリがないものかと探した経緯があります。

シリアライズしたいケースとして、異なるプラットフォーム同士で通信するプログラムを作りたいケースがありますので、この性質は大いに作業を楽にしてくれます。

cerealは大変インターフェースがboost::serializationに似通っており、こちらを使ったことがある人にもおすすめできます。

こんな便利なのに、日本語の記事が見当たりませんので、せっかくなので簡単にまとめてみます。

あ、ちなみにライセンスは三条項BSDライセンスになります


環境構築

まずは本体を手に入れましょう。

現状安定版は、v1.1.2のようです。github.ioページ右上のDownloadからもらってくるのが楽でよいでしょう。

http://uscilab.github.io/cereal/index.html

もちろんgithubからとってきても大丈夫です。

https://github.com/USCiLab/cereal

本体とってきたら、

解凍して、直下にできるincludeに、おのおのの環境でパスを通しましょう。

visual studioなら追加のインクルードディレクトリ、xcodeならheader search pathですね。

その他の環境でもヘッダーが見えるようになっていれば大丈夫です。

C++11が使えることをついでに確認しておきましょう。


始める

素晴らしく簡単に環境が整いました。

C++界隈のライブラリには環境構築が難しいものが結構たくさんありますので、こういうライブラリは本当にありがたいです。

では、最初のプログラムです

#include <iostream>

#include <sstream>
#include <string>

#include <cereal/cereal.hpp>
#include <cereal/archives/json.hpp>

struct Pokemon {
std::string name;
int hp = 0;

template<class Archive>
void serialize(Archive & archive)
{
archive(name, hp);
}
};

int main()
{
Pokemon pokemon;
pokemon.name = "PIKACHU";
pokemon.hp = 100;

std::stringstream ss;
{
cereal::JSONOutputArchive o_archive(ss);
o_archive(pokemon);
}
std::cout << ss.str() << std::endl;

Pokemon pokemon_i;
cereal::JSONInputArchive i_archive(ss);
i_archive(pokemon_i);

std::cout << pokemon_i.name << std::endl;
std::cout << pokemon_i.hp << std::endl;

#ifdef _MSC_VER
system("pause");
#endif
return 0;
}

出力



{
"value0": {
"value0": "PIKACHU",
"value1": 100
}
}
PIKACHU
100

ね?簡単でしょう?

serializeメンバ関数がtemplateであることで、あらゆるシリアライザに対応でき、

かつシリアライズとデシリアライズのコードが共通化されています。

これを見ると自分で保存と再現のロジックを書くのが馬鹿らしくなりますね。

もちろん巨大なデータやパフォーマンスを極限まで求めるケースなんかは独自フォーマットを書いたほうが良いでしょう。

さて、ここで大変重要な約束事として、

std::stringstreamから.str()で文字列を取得する前に絶対にcereal::JSONOutputArchiveが死んでいないといけない

というのがあります。

いいですか?

std::stringstreamから.str()で文字列を取得する前に絶対にcereal::JSONOutputArchiveが死んでいないといけない

大事なことなので2回言いました。

これはstringstreamに限った話ではなく、出力のstreamすべてにいえることです。

というのも、cerealのシリアライズは、RAIIイディオムに元づいており、破棄のタイミングまで出力が完全にはされない可能性があるのです。

これのせいで自分、jsonの}が一つ出力されず、小一時間無駄にしました。これは公式サイトにもちゃんと記述があるのですが、サンプルコードが簡単だったので自分はよくドキュメントを読まなかったんです(ぉ。

バグかとおもってissueを読んで初めて気づきました。


名前を付ける

せっかくjsonなのにキーがちょっと残念です。

そこで名前を付けてみましょう。


#include <iostream>
#include <sstream>
#include <string>

#include <cereal/cereal.hpp>
#include <cereal/archives/json.hpp>

struct Pokemon {
std::string name;
int hp = 0;

template<class Archive>
void serialize(Archive & archive)
{
archive(CEREAL_NVP(name), CEREAL_NVP(hp));
}
};

int main()
{
Pokemon pokemon;
pokemon.name = "PIKACHU";
pokemon.hp = 100;

std::stringstream ss;
{
cereal::JSONOutputArchive o_archive(ss);
o_archive(cereal::make_nvp("root", pokemon));
}
std::cout << ss.str() << std::endl;

Pokemon pokemon_i;
cereal::JSONInputArchive i_archive(ss);
i_archive(cereal::make_nvp("root", pokemon_i));

std::cout << pokemon_i.name << std::endl;
std::cout << pokemon_i.hp << std::endl;

#ifdef _MSC_VER
system("pause");
#endif
return 0;
}

出力



{
"root": {
"name": "PIKACHU",
"hp": 100
}
}
PIKACHU
100

大変読みやすくなりました。

シリアライズされた結果がすぐ目に見えること、編集しやすいこと、

これってとっても開発やデバッグ大きなメリットをもたらすと自分は思います。

別なツールをかませることもできれば、正規表現に食わせることだってできますね。

※ここからはデシリアライズのコードはいったん省略します。

※ちなみに名前を付けた場合、archiveは順不同になるメリットもあります。


非侵入型

シリアライズするとき、必ずしも自分のオブジェクトであるわけではありません。既存のソースの場合もあります。

そんな時にはこうしましょう。

vectorの引数が参照であることに注意しましょう。


#include <iostream>
#include <sstream>
#include <string>

#include <cereal/cereal.hpp>
#include <cereal/archives/json.hpp>

// 既存のなんらかの外部ライブラリの型
struct Vector2 {
float x = 0.0f;
float y = 0.0f;
};

// 非侵入型のシリアライズ定義
template<class Archive>
void serialize(Archive & archive, Vector2 &vector)
{
archive(cereal::make_nvp("x", vector.x), cereal::make_nvp("y", vector.y));
}

struct Pokemon {
std::string name;
int hp = 0;
Vector2 position;

template<class Archive>
void serialize(Archive & archive)
{
archive(CEREAL_NVP(name), CEREAL_NVP(hp), CEREAL_NVP(position));
}
};

int main()
{
Pokemon pokemon;
pokemon.name = "PIKACHU";
pokemon.hp = 100;
pokemon.position.x = 5.0f;
pokemon.position.y = 12.0f;

std::stringstream ss;
{
cereal::JSONOutputArchive o_archive(ss);
o_archive(cereal::make_nvp("root", pokemon));
}
std::cout << ss.str() << std::endl;

#ifdef _MSC_VER
system("pause");
#endif
return 0;
}

出力

{

"root": {
"name": "PIKACHU",
"hp": 100,
"position": {
"x": 5,
"y": 12
}
}
}

素晴らしいですね。

※ただし、対象のクラスが名前空間に所属している場合、その名前空間で定義しなければならないようです


標準の型

標準STLの型ももちろん使えます。

#include が追加されていることだけ注意してください。


#include <iostream>
#include <sstream>
#include <string>

#include <cereal/cereal.hpp>
#include <cereal/archives/json.hpp>
#include <cereal/types/vector.hpp>

struct Vector2 {
float x = 0.0f;
float y = 0.0f;
};

template<class Archive>
void serialize(Archive & archive, Vector2 &vector)
{
archive(cereal::make_nvp("x", vector.x), cereal::make_nvp("y", vector.y));
}

struct Pokemon {
std::string name;
int hp = 0;
Vector2 position;
std::vector<std::string> moves;

template<class Archive>
void serialize(Archive & archive)
{
archive(CEREAL_NVP(name), CEREAL_NVP(hp), CEREAL_NVP(position), CEREAL_NVP(moves));
}
};

int main()
{
Pokemon pokemon;
pokemon.name = "PIKACHU";
pokemon.hp = 100;
pokemon.position.x = 5.0f;
pokemon.position.y = 12.0f;
pokemon.moves.push_back("Swift");
pokemon.moves.push_back("Thundervolt");

std::stringstream ss;
{
cereal::JSONOutputArchive o_archive(ss);
o_archive(cereal::make_nvp("root", pokemon));
}
std::cout << ss.str() << std::endl;

#ifdef _MSC_VER
system("pause");
#endif
return 0;
}

出力

{

"root": {
"name": "PIKACHU",
"hp": 100,
"position": {
"x": 5,
"y": 12
},
"moves": [
"Swift",
"Thundervolt"
]
}
}

ちなみに、movesは技、Swiftがスピードスター、Thundervoltは十万ボルトです(わりとどうでもいいですかね)。


バージョン付け

シリアライズにはバージョン付けが欠かせないといってもいいでしょう。

開発中、アプリバージョン違いなどは非常によくある課題です。

cerealには標準でその機能がついています。

引数のstd::uint32_t const versionで分岐させることができます。

CEREAL_CLASS_VERSIONマクロでバージョンを指定できます。

なお、バージョンを気にしなければならないのはほとんど読み込みの時だけだというのを一応頭に入れておきましょう。

というのも、書き込むときには常に最新であることがほとんどだからです。

どうしても、というときは違う型にして、cereal::make_nvpで整合性を保つ方法もありますので、できないということではありません。

#include <iostream>
#include <sstream>
#include <string>

#include <cereal/cereal.hpp>
#include <cereal/archives/json.hpp>
#include <cereal/types/vector.hpp>

struct Vector2 {
float x = 0.0f;
float y = 0.0f;
};

template<class Archive>
void serialize(Archive & archive, Vector2 &vector)
{
archive(cereal::make_nvp("x", vector.x), cereal::make_nvp("y", vector.y));
}

struct Pokemon {
std::string name;
int hp = 0;
Vector2 position;
std::vector<std::string> moves;

template<class Archive>
void serialize(Archive & archive, std::uint32_t const version)
{
archive(CEREAL_NVP(name), CEREAL_NVP(hp), CEREAL_NVP(position), CEREAL_NVP(moves));
if (1 <= version) {
// archive(...);
}
}
};
CEREAL_CLASS_VERSION(Pokemon, 1);

int main()
{
Pokemon pokemon;
pokemon.name = "PIKACHU";
pokemon.hp = 100;
pokemon.position.x = 5.0f;
pokemon.position.y = 12.0f;
pokemon.moves.push_back("Swift");
pokemon.moves.push_back("Thundervolt");

std::stringstream ss;
{
cereal::JSONOutputArchive o_archive(ss);
o_archive(cereal::make_nvp("root", pokemon));
}
std::cout << ss.str() << std::endl;

#ifdef _MSC_VER
system("pause");
#endif
return 0;
}

出力



{
"root": {
"cereal_class_version": 1,
"name": "PIKACHU",
"hp": 100,
"position": {
"x": 5,
"y": 12
},
"moves": [
"Swift",
"Thundervolt"
]
}
}


スレッド安全

落とし穴として、Cerealはデフォルトではスレッド安全ではありません。そのまま複数スレッドからシリアライズが行われると未定義動作につながり、クラッシュなどを引き起こすので注意が必要です。なのでスレッド安全性が必要な場合、以下のようなマクロをヘッダーをインクルードする前に定義するか、

// Before including any cereal header file

#define CEREAL_THREAD_SAFE = 1

// Now include your cereal headers
#include <cereal/cereal.hpp>
// etc

ややお行儀が悪いですが、cereal/macros.hpp を直接書き換えてしまう方法もあります。


#ifndef CEREAL_THREAD_SAFE
//! Whether cereal should be compiled for a threaded environment
/*! This macro causes cereal to use mutexes to control access to
global internal state in a thread safe manner.

Note that even with this enabled you must still ensure that
archives are accessed by only one thread at a time; it is safe
to use multiple archives in paralel, but not to access one archive
from many places simultaneously. */
#define CEREAL_THREAD_SAFE 0
#endif // CEREAL_THREAD_SAFE

Cereal Thread Safety


まとめ

ここまでできれば、大抵のことはできるといってもいいでしょう。

後の詳細は公式のドキュメントを当たれば大丈夫かと思います。

これでみなさんもC++快適シリアライズライフを送りましょう。

enjoy!