シリアライザを作る

シリアライザを作った際のメモです。
シリアライザとは、構造体等のオブジェクトをバイト列に変換する機能です。通信関係のライブラリを作る際に必要になったため自作しました。
実装する際には、環境の違いによって変換後のバイト列の順序やサイズが異なる可能性があるためなかなか大変です。オブジェクトをバイト列に変換した際、CPU のエンディアンや ABI、コンパイラの仕様によって、データの配置やパディングの有無が変わることがあります。そのためこれらを完全に理解し、環境差を吸収する実装をしないといけないです:D
実装時に気をつける環境の違いによる影響:
- エンディアンが異なる点
- 浮動小数点型のサイズが異なる点
- 構造体のパディングサイズが異なる点
実装したこと:
- メンバ変数を列挙する黒魔術(パディングによるサイズの違いの対策)
- シリアライズ後のサイズをコンパイル時に算出する
- エンディアン変換
- 浮動小数点型のサイズが異なる問題の対策
エンディアン
オブジェクトをメモリに配置する際のバイト列の並び順のことです。主にビッグエンディアンとリトルエンディアンの 2 種類があります。
ビッグエンディアンは上位バイトから下位バイトに向かって配置されます。インターネッツの世界はこれです。イメージ:
mem[offset + 0] = obj[0]
mem[offset + 1] = obj[1]
mem[offset + 2] = obj[2]
リトルエンディアンは下位バイトから上位バイトに向かって配置されます。最初に知った時は驚きました。一般的に普及している CPU はリトルエンディアンです。イメージ:
mem[offset + 0] = obj[2]
mem[offset + 1] = obj[1]
mem[offset + 2] = obj[0]
C 言語はエンディアンの違いを隠蔽してくれるという意味では高級言語と言えるかもしれません。そんな感じでエンディアンの対策をしていないと、エンディアンが異なる環境間でデータのやり取りができないという訳です。
私は常にリトルエンディアンの環境に合わせるように実装しました。ビッグエンディアンの環境ではバイト列を逆転させます。
サイズが異なる点
実は int
やら float
、double
やらのサイズは C の規格で規定されてておらず、ABI (バイナリレベルのデータ順などを規定する既約)によって決定されます。
https://learn.microsoft.com/ja-jp/cpp/build/x64-software-conventions#scalar-types
整数型は stdint.h で定義されている uint32_t
, int64_t
型等を使うことでサイズを保証できますが、浮動小数点型の場合そうはいきません!
この問題、ググっても ChatGP っても解決せず、1 ヶ月くらい食われました。最終的に boost ライブラリに boost::float**_t
という型がありこれを参考にして実装しました。boost では float.h に定義されている浮動小数点型の精度のマクロを利用して floatXX_t 型を定義してるようです。勉強になる 👁👄👁
https://www.boost.org/doc/libs/1_74_0/boost/math/cstdfloat/cstdfloat_types.hpp
パディングによるサイズの違い
構造体のオブジェクトがメモリに配置される際、(空の詰め物)が入ることがあります。この詰め物のサイズも ABI に基づいてコンパイラが決めるので、環境によってオブジェクトのサイズが異なることがあります。
因みにパディングはメモリアクセスの高速化のために挿入されます。アライメント境界を超えてオブジェクトが配置されると、複数回に分けてメモリアクセスする必要があるため、アライメントを揃えるために詰め詰めします。
なのでこんなコードはアウト。
struct Token
{
char type;
/* 大抵ここにパディングが */
int value;
};
int main()
{
Token tok;
uint8_t buf[sizeof(Token)];
std::memcpy(buf, &tok, sizeof(Token)); // オブジェクトをバイト列に変換
send(buf); // 送信
}
関係ないですが、キャストによってバイト列に再解釈するのは禁忌になる可能性があります。共用体を使うのも同じくまずいです。strict aliasing rules に違反し、未定義動作となる可能性があります。char か char 修飾のポインタへのキャストは許されています。以下の場合、uint8_t が unsigned char で typedef されているか定かではないのでギリアウトです。
int main()
{
Token tok;
uint8_t* buf = reinterpret_cast<uint8_t*>(&tok);
}
https://opaupafz2.hatenablog.com/entry/2024/03/31/120001
アライメント境界をいじることも出来ますが、行儀がよろしくない気がします。ではどうするかというと、愚直にメンバ変数を一つずつ列挙しバイト列に詰めていきます。
しかし C++ には C# のリフレクションのようにメンバ変数を列挙するような仕組みがありません(嫌な予感)。C++ の伝家の宝刀、テンプレートメタメタプログラミングの出番です。
USCiLab/cereal では以下のように、構造体の内部に列挙用の関数を自分で定義することでメンバ変数を列挙しています。これを参考に実装しました。
struct MyRecord
{
uint8_t x, y;
float z;
template <class Archive>
void serialize( Archive & ar )
{
ar( x, y, z );
}
};
テンプレート引数になっている Archive 型のインスタンスが列挙します。いろんなクラス(例えばシリアライザ、デシリアライザなど) が列挙できるように、テンプレート引数になっています。
シリアライズを行う関数を考えます。次のように任意のオブジェクトを受け取り、バイト列を返すような関数です。
template <typename T>
std::vector<uint8_t> Serialize(const T& obj)
{
// ここを今から実装する!
}
int main()
{
int value = 100;
std::vector<uint8_t> buf = Serialize(value);
// buf を送信
}
Archive に入る列挙者クラス (Serializer) を実装していきます。ar( x, y, z )
という形で呼び出されているため、可変長引数を持つ関数呼び出し演算子のオーバーロードを実装する必要があることが分かります。
struct MyRecord
{
uint8_t x, y;
float z;
template <class Archive> // <- Archive に Serializer が入る
void serialize( Archive & ar )
{
ar( x, y, z ); // <- operator()(Args&&... args) が呼ばれる
}
};
class Serializer
{
public:
template <typename... Args>
void operator()(Args&&... args)
{
unpack(std::forward<Args>(args)...);
}
};
template <typename T>
std::vector<uint8_t> Serialize(const T& obj)
{
Serializer e;
obj.serialize(e);
//
}
すると、obj.serialize(e)
を呼んだ際に、e( x, y, z )
が呼ばれ、メンバ変数をゲットできます。可変長引数のままでは扱えないので展開します。C++11 で実装します。
class Serializer
{
public:
template <typename... Args>
void operator()(Args&&... args)
{
unpack(std::forward<Args>(args)...);
}
private:
template <typename Head, typename... Tails>
void unpack(Head&& head, Tails&&... tails)
{
serialize(std::forward<Head>(head)); // 頭だけ取り出し再帰呼び出し
unpack(std::forward<Tails>(tails)...);
}
void unpack() {} // 再起の最後は引数がないのでこれが呼ばれる
template <typename T>
void serialize(T&& value)
{
// バイト列に詰める
}
};
するとメンバ変数毎に serialize
関数が呼ばれます。これでメンバ変数を列挙できるようになりました。では次にメンバ変数の型に応じて呼ばれる関数を切り替えるようにします。SFINAE (Substitution Failure Is Not An Error) という機能を使うと、型によって関数を切り替えることができます。簡単な例だと次のような感じ。長いので他は省いてますがメンバ関数です。
class Serializer
{
//...
template <typename T, typename std::enable_if<std::is_integral<T>::value, std::nullptr_t>::type = nullptr>
void serialize(T&& value)
{
// 整数型の処理
}
template <typename T, typename std::enable_if<std::is_floating_point<T>::value, std::nullptr_t>::type = nullptr>
void serialize(T&& value)
{
// 浮動小数点型の処理
}
//...
};
因みに、typename std::enable_if...
の typename
は typename T
の typename
とは意味が異なります。C++ ではネストされた型名 (型の中に型があるやつ。今回だと std::enable_if<...>::type
) を指定する際に typename std::enable_if<>::type
としてやる必要があります!!
ただこれだと問題がありまして、対応していない型が渡ってくると、どえらいコンパイルエラーになります。そこで対応していない型が渡ってきた場合 static_assert でエラーを出すようにします。
class Serializer
{
//...
template <typename T>
void serialize(T&& value)
{
static_assert(std::is_integral<T>::value || std::is_floating_point<T>::value, "対応していない型です");
}
//...
};
これでメンバ変数の列挙ができるようになりました。しかし、このままだとエラー条件 (std::is_integral<T>::value || ...
)がどんどん長くなるので、部分特殊化を用いて、先に全条件が成立しない場合の関数を定義し、条件を満たす場合のみ他の関数が実体化されるようにします。メンバ関数の部分特殊化はできないので、部分特殊化された構造体でラップします。↓ みたいな
class Serializer
{
//...
template <typename Head, typename... Tails>
void unpack(Head&& head, Tails&&... tails)
{
Serialize<Head>::serialize(std::forward<Head>(head));
unpack(std::forward<Tails>(tails)...);
}
//...
template <typename T>
struct AlwaysFalse : std::false_type {};
template <typename T, typename = void>
struct Serialize
{
static void serialize(T&& value)
{
static_assert(AlwaysFalse<T>::value, "対応していない型です");
}
};
template <typename T>
struct Serialize<T, typename std::enable_if<std::is_integral<T>::value>::type>
{
static void serialize(T&& value)
{
// 整数型の処理
}
};
template <typename T>
struct Serialize<T, typename std::enable_if<std::is_floating_point<T>::value>::type>
{
static void serialize(T&& value)
{
// 浮動小数点型の処理
}
};
//...
};
構造体でラップしたことで、Serializer
クラスの this ポインタへアクセス出来なくなりました。シリアライザ後のバイト列は Serializer
クラスのメンバ変数が持つのでこれでばバイト列にアクセスできません。serialize 関数の第一引数に this ポインタを渡すようにします。
class Serializer
{
//...
template <typename Head, typename... Tails>
void unpack(Head&& head, Tails&&... tails)
{
Serialize<Head>::serialize(this, std::forward<Head>(head));
unpack(std::forward<Tails>(tails)...);
}
//...
template <typename T, typename = void>
struct Serialize
{
static void serialize(Serializer* self, T&& value)
{
static_assert(AlwaysFalse<T>::value, "対応していない型です");
}
};
template <typename T>
struct Serialize<T, typename std::enable_if<std::is_integral<T>::value>::type>
{
static void serialize(Serializer* self, T&& value)
{
// 整数型の処理
}
};
template <typename T>
struct Serialize<T, typename std::enable_if<std::is_floating_point<T>::value>::type>
{
static void serialize(Serializer* self, T&& value)
{
// 浮動小数点型の処理
}
};
//...
};
これで再帰的な列挙ができるようになりました。あとはバイト列に詰める処理を実装します。
class Serializer
{
//...
std::vector<uint8_t> buffer; // あらかじめメモリが確保されているとする
size_t pushIndex = 0;
//...
template <typename T, typename = void>
struct Serialize
{
static void serialize(Serializer* self, T&& value)
{
static_assert(AlwaysFalse<T>::value, "対応していない型です");
}
};
template <typename T>
struct Serialize<T, typename std::enable_if<std::is_integral<T>::value>::type>
{
static void serialize(Serializer* self, T&& value)
{
self->pushArithmetic(std::forward<T>(value));
}
};
template <typename T>
struct Serialize<T, typename std::enable_if<std::is_floating_point<T>::value>::type>
{
static void serialize(Serializer* self, T&& value)
{
self->pushArithmetic(std::forward<T>(value));
}
};
//...
template <typename Arithmetic>
void pushArithmetic(Arithmetic arithmetic)
{
constexpr auto size = sizeof(Arithmetic);
// オブジェクトをバイト列として解釈
memcpy(
buffer.data() + pushIndex,
std::addressof(arithmetic),
size);
// 次に挿入するインデックスを更新
pushIndex += size;
}
//...
};
これではまだ不十分で、構造体のメンバ変数の型が構造体の場合、再帰的に列挙するようにしたり、構造体のメンバ変数が配列の場合の処理を追加する必要があります。
すいません。力尽きました(’_')