豪鬼メモ

一瞬千撃

C++で任意の複合型をシリアライズする

C++で任意の複合型をシリアライズするユーティリティ関数群を実装してみた。JSONやProtobufを入れるのさえ面倒というようなちょっとしたユースケースでは便利なはずだ。以下の図を見るとわかっていただけると思うが、静的型付けの言語であるC++においても、任意の型のデータを文字列にシリアライズして格納すれば文字列のコンテナを使うだけで任意の複合型が表現できる。それをさらにシリアライズして文字列にすればファイル保存やネットワーク転送が可能になる。


前回の記事浮動小数点数シリアライズ関数を実装したが、勢いで基本型だけでなくそれらを組み合わせた任意の複合型も扱えるようにしたくなってきた。任意の複合型のオブジェクトをデータベースに格納したりネットワークで転送したりするには、任意の複合型に対応したシリアライズ方法が必要となる。シリアライズ後のデータが人間可読なテキストになるJSONYAMLXMLなどを使っても良いし、シリアライズ後のデータが人間可読でないバイナリであるProtocol BuffersやBSONやMessagePackなどを使っても良い。というか、小規模なシステムで初期開発コストを抑えたいならJSON、大規模なシステムで長期的なメンテコストを抑えたいならProtocol Buffersを選ぶべきだろう。

今回のシリアライズ関数の実装は、もっと小規模な、個人的な用途を想定している。趣味や研究などの用途で自分で書いて自分で使う実装で簡単に使えるユーティリティを目指した。C++環境ではJSONのライブラリが標準化されていないので、JSONライブラリを選んで入れるという作業が面倒くさい。というか、それを前提としたコードを提供する際にサードパーティのライブラリへの依存関係があると、それについて他人に説明しなきゃならんのが面倒くさい。さらに、Tkrzwのサンプルコードで任意の複合型のオブジェクトをシリアライズして格納する方法を説明するのに外部ライブラリに依存するのは避けたい。

ということで、C++標準ライブラリだけに依存したちょっとしたテンプレート関数を何個か書くだけで、JSONユースケースをカバーする機能性を獲得する方策について考えてみた。具体的に必要な機能は以下のものだ。

  • 任意の文字列はそのまま任意のバイナリ文字列(std::string)として扱う
  • 文字列のベクタ(std::vector)をバイナリ文字列に変換する
  • 任意の数値型(int8~64とそれらのunsigned、またfloatとdouble)をバイナリ文字列に変換する
  • 任意の数値型のベクタをバイナリ文字列に変換する
  • 文字列マップ(std::map) にバイナリ文字列を格納する
  • 文字列マップをバイナリ文字列に変換する

以上の機能があれば、JSONでできることは全てできるだろう。ところで、Tkrzwの文字列ライブラリ(tkrzw_str_util.h)には私が欲しいと思った文字列処理関数をしこたまぶちこんである。文字列のベクタのシリアライズをするtkrzw::SerializeStrVector、そのデシリアライズをするtkrzw::DeserializeStrVectorは既にある。また、文字列マップをシリアライズするSerializeStrMap、そのデシリアライズをするtkrzw::DeserializeStrMapも既にある。

std::string tkrzw::SerializeStrVector(const std::vector<std::string>& values);

std::vector<std::string> tkrzw::DeserializeStrVector(std::string_view serialized);

std::string tkrzw::SerializeStrMap(const std::map< std::string, std::string > &records)

std::map<std::string, std::string> tkrzw::DeserializeStrMap(std::string_view serialized);

あとは、任意の数値型およびそのベクタのシリアライズとデシリアライズ関数を実装すれば良い。Tkrzw 1.0.30から以下の関数群が導入されている。型名をパラメトライズしたテンプレートなので、数値型ならどれもいける。実体が連続的で単純複製可能(trivially copyable)であれば、構造体でも共用体でも扱えるので、複素数も入れられる。

template<typename T>
inline std::string SerializeBasicValue(T value);

template<typename T>
inline T DeserializeBasicValue(std::string_view serialized);

template<typename T>
inline std::string SerializeBasicVector(const std::vector<T>& values);

template<typename T>
inline std::vector<T> DeserializeBasicVector(std::string_view serialized);

今回数値型のシリアライザを導入するにあたって、割り切ったことがある。外部システムとの相互運用性は文字列と数値の互換性に限定し、それ以外のアラインメントやその他内部表現の調整をしないということだ。すなわち、文字列は単にバイト列としてそのまま格納し、int16、int32、int64などの整数型と、floatとdoubleの浮動少数型のバイトオーダーは、ビッグエンディアンに正規化する。floatとdoubleはほとんどのシステムでIEEE754準拠だろうから、バイトオーダー以外のフォーマットの差異は考えないものとする。それより高位な構造体などの相互運用性は知らんぷりだ。例えば、複素数型(complex)の内部表現はある処理系では struct { double re; double im; } に相当する並びかもしれないし、sturct { double im; double re; } と逆の並びになっているかもしれないので、ある処理系で作ったデータベースを別の処理系で読み出すと同値性が保証されない。もしそれを気にするなら、complexの代わりに vectorシリアライズすれば良いだろう。なお、long doubleがどうなるかも処理系依存だ。sizeofの値だけで言えば32ビット系なら12バイト、64ビット系なら16バイトなのが普通っぽいが、中身の形式がどうなのかとは別の話だ。コンパイラのオプション(-mlong-double-128とか)でも変わる。

中身はどうあれ、APIは単純だ。細かいことは気にしなくても使えるようになっている。具体例を見てみよう。ここでは、JSONで表現するなら以下のようになるデータを想定する。文字列、整数、実数、文字列のリスト、整数のリスト、実数のリストを含む複合型だ。

{
  "name": "John Doe",
  "id": 98765,
  "rating": 0.8,
  "medals": ["gold", "silver", "bronze"],
  "rivals": [12345, 65432],
  "scores": [48.9, 52.5, 60.3],
}

こいつをDBMに格納して、それから取り出して、表示する簡単なお仕事をコードに落とし込む。

#include <map>
#include <vector>
#include "tkrzw_dbm_hash.h"
#include "tkrzw_str_util.h"

// メインルーチン。
int main(int argc, char** argv) {
  using namespace tkrzw;

  // データベースを開く。
  HashDBM dbm;
  dbm.Open("casket.tkh", true, File::OPEN_TRUNCATE);

  // 複合データを格納した文字列マップを構築する。
  std::map<std::string, std::string> record;
  record["name"] = "John Doe";
  record["id"] = SerializeBasicValue<int32_t>(98765);
  record["rating"] = SerializeBasicValue<double>(0.8);
  std::vector<std::string> medals = {"gold", "silver", "bronze"};
  record["medals"] = SerializeStrVector(medals);
  std::vector<int32_t> rivals = {123456, 654321};
  record["rivals"] = SerializeBasicVector(rivals);
  std::vector<double> scores = {48.9, 52.5, 60.3};
  record["scores"] = SerializeBasicVector(scores);

  // シリアライズしてデータベースに格納する。
  dbm.Set("john", SerializeStrMap(record));

  // データベースに格納したレコードを取り出して文字列マップに直す。
  std::string serialized;
  std::map<std::string, std::string> restored;
  if (dbm.Get("john", &serialized).IsOK()) {
    restored = DeserializeStrMap(serialized);
  }

  // データ構造に基づいて内容を解析して表示する。
  std::cout << "name: " << restored["name"] << std::endl;
  std::cout << "id: " <<
      DeserializeBasicValue<int32_t>(restored["id"]) << std::endl;
  std::cout << "rating: " <<
      DeserializeBasicValue<double>(restored["rating"]) << std::endl;
  for (const auto& value : DeserializeStrVector(restored["medals"])) {
    std::cout << "medals: " << value << std::endl;
  }
  for (const auto& value :
           DeserializeBasicVector<int32_t>(restored["rivals"])) {
    std::cout << "rivals: " << value << std::endl;
  }
  for (const auto& value :
           DeserializeBasicVector<double>(restored["scores"])) {
    std::cout << "scores: " << value << std::endl;
  }
  
  // データベースを閉じる。
  dbm.Close();

  return 0;
}

基本型とそのリストを文字列化する関数と、それを格納する文字列マップをさらに文字列化する関数を用意しておけば、それらを入れ子にすることでいくらでも複雑な形を扱うことができる。JSONやProtobufと違ってシリアライズされたデータ自体には元の構造の情報が全く含まれていないので、想定した構造に基づいて元のデータを復元するコードはプログラマの責任で書かなければならない。

実装が単純なので時間効率はかなり良いと思うのだが、空間効率が良いかどうかはデータの内容に依存する。型情報を完全に省いているのでフットプリントが小さいというのは、JSONやProtobufに比べて利点だ。一方で、数値を固定長バイナリで表現しているので、小さい値をint64とかに入れても確実に8バイトの空間を食うことになり、その点では可変長で表現するJSONやProtobufには劣る場合がある。変域に合わせてint8, int16, int32とそれらのunsignedも使えばよいのだが、ちょっと面倒くさい。

そこで、整数及び整数の数値配列に限定して、可変長エンコーディングも用意した。0から2^7-1までは1バイト、それを超えて2^14-1までは2バイト、それを超えて2^21-1までは3バイト、それを超えて2^28-1までは4バイトといった感じで、小さい数値が多いと得になる形式だ。APIは以下のようになる。

std::string IntToStrDelta(uint64_t data, bool zigzag);

uint64_t StrToIntDelta(std::string_view str, bool zigzag);

template<typename T>
inline std::string SerializeIntVectorDelta(const std::vector<T>& values, bool zigzag);

template<typename T>
inline std::vector<T> DeserializeIntVectorDelta(std::string_view serialized, bool zigzag);

普通に可変長エンコーディングを施すと、負数が扱えない。よって、デフォルトではzigzagエンコーディングを適用する。0から始まる整数列が表す値を0、-1、1、-2、2、-3、3、といった具合に割り振るのだ。最大値と最小値がオーバーフローしないように変換するには以下のような関数を用いる。

uint64_t zigzag(int64_t num) {
  if (num < 0) {
    return static_cast<uint64_t>((num + 1) * -2) + 1;
  }
  return num * 2;
}

int64_t unzigzag(uint64_t num) {
  if (num & 0x1) {
    return static_cast<int64_t>((num - 1) / 2) * -1 - 1;
  }
  return num / 2;
}

zigzagエンコーディングを施さなければ0から127までが1バイトになるところ、zigzagエンコーディングを施すと-64から63までが1バイトになる。なので、正数しか入らない数列だと分かっているならzigzagさせない方が良いので、パラメータで選択できるようになっている。年齢とか日付とかだと閾値が63なのか127なのかの違いは結構効いてくるだろう。使い方はこんな感じになる。

int64_t num = 123;
std::string num_str = IntToStrDelta(num, true);
int64_t restored_num = StrToIntDelta(num_str, true);

uint64_t unum = 123;
std::string unum_str = IntToStrDelta(unum, false);
uint64_t restored_unum = StrToIntDelta(unum_str, false);

std::vector<int64_t> numvec = {-123, 0, 123};
std::string numvec_str = SerializeIntVectorDelta(numvec, true);
std::vector<int64_t> restored_numvec = DeserializeIntVectorDelta<int64_t>(numvec_str, true);

std::vector<uint64_t> unumvec = {0, 123, 18235};
std::string unumvec_str = SerializeIntVectorDelta(unumvec, false);
std::vector<uint64_t> restored_unumvec = DeserializeIntVectorDelta<uint64_t>(unumvec_str, false);

これで、空間効率でProtobufに優る方式であると言えるようになった。ただ、冷静に考えると、変域が限定できないのに実際の値が小さいものに偏る数列というのはそんなにない。Protobufだと変域を考えるのが面倒だから数値はとりあえず可変長のint32とかにして固定長のsfixed32とかは使わないことが多いし、保守性を考えるとそれが実践的なのだが、可変長の空間効率が固定長のそれより常に良いわけではないということは踏まえておきたいところだ。

シリアライズとデシリアライズのコードが散在しているのは保守性に問題を生じるので、普通はクラスや構造体を定義してそのメソッドとしてシリアライズとデシリアライズを実装するだろう。今回定義したユーティリティ関数群はその中で使うべきものだ。

struct Student {
  std::string name;
  uint64_t id = 0;
  double rating = 0.0;
  std::vector<std::string> medals;
  std::vector<uint64_t> rivals;
  std::vector<double> scores;

  std::string Serialize() const;
  void Deserialize(std::string_view str);
};

まとめ。JSONやProtobufを使わなくても、基本型やベクタやマップをシリアライズしたりデシリアライズしたりする個別の関数を用意してやれば、任意の複合型のシリアライズできる。C++でちょっとしたデータ管理がしたい場合にはそれらを使うと便利かもしれない。まあ、ある程度以上の規模や複雑性になるならさっさとJSONやProtobufを導入すべきだけども。