【Rust】zerompk - Rust向けの最速MessagePack実装とその最適化手法
zerompk、というRust向けのMessagePackシリアライザをリリースしました!
RustでMessagePack、というと真っ先に出てくる選択肢はrmp_serdeでしょう。rmp_serdeは汎用性・性能ともに十分ではありますが、zerompkはさらに性能特化の実装になっています。
ベンチマークを見てみましょう。

ベンチマークには参考としてserde_jsonも追加してあります。JSONよりバイナリであるMessagePackのほうが高速なのはそれはそう、ですが、同じMessagePackシリアライザであるrmp_serdeと比較してもかなりの差があります。
Rustは言語自体が高速になるようデザインされていることから既に最適化し尽くされているイメージがありますが、意外とそんなことはなく、丁寧にチューニングされた実装を叩き込めばそれなりの差は生じます。というか思ったよりrmp_serdeが遅い。
ただし、詳細は後述しますが、zerompkは最高速の達成(およびゼロ依存)のために汎用性を若干犠牲にしています。とはいえカスタマイズの口はそれなりに用意してあるので、大きく問題になることはないはずです。シリアライズされるバイナリもちゃんとrmp_serdeと互換性があるので、いざとなれば併用することも可能ではあります。
使い方
使い方は基本的にREADMEを参照してもらえばいいんですが、実装解説のために軽く説明しておきます。
use zerompk::{FromMessagePack, ToMessagePack};
// シリアライズする型にはFromMessagePack, ToMessagePackの実装が必要
// serde::Serialize/Deserializeではダメです!Oh...
#[derive(FromMessagePack, ToMessagePack)]
#[msgpack(array)] // array/mapから選択可能、デフォルトはarray
pub struct Person {
#[msgpack(key = 0)] // keyの指定、可能な限り明示することを推奨
pub name: String,
#[msgpack(key = 1)]
pub age: u32,
#[msgpack(ignore)] // 無視するフィールドにはignoreを付与
pub metadata: Metadata,
}
// どうでもいいので中身は省略
#[derive(Default)] // ignoreなフィールドをデシリアライズするにはDefaultが必要
pub struct Metadata;
fn main() {
let person = Person {
name: "Alice".to_string(),
age: 18,
};
// シリアライズ
let mut buf = vec![0; 256];
let msgpack: Vec<u8> = zerompk::to_msgpack(&person, &mut buf)
.unwrap();
// デシリアライズ
let person: Person = zerompk::from_msgpack(&msgpack)
.unwrap();
}
FromMessagePack/ToMessagePackを実装し、zerompk::from_msgpack/zerompk::to_msgpack()を使うのが基本です。これらのAPIは受け取った[u8]スライスを直接読み書きするもので、これを使うのが基本かつ最速になります。
std::io::{Read, Write}によるストリーミングにも対応しており、read_msgpack()/write_msgpack()が利用できます。
fn main() {
let file = std::fs::File::open("example.msgpack")
.unwrap();
// ファイルに対する連続したread呼び出しは遅いため、必ずBufReaderを介します
// zerompk側でバッファリングを行うことはありません。
// これはrmp_serdeやserde_jsonでも同様です。
let mut buf_reader = std::io::BufReader::new(file);
// Readを用いたデシリアライズ
let person: Person = zerompk::read_msgpack(&mut buf_reader)
.unwrap();
}
ただし、これは直感的ではないかもしれませんが、バッファリングしてなおstd::io::Readを介した読み取りは低速です。サイズ次第ではありますが、そこまで大きくなければメモリに一度展開してから読み取る方が高速になります。(ちなみに、これはserde_jsonのドキュメントにも明記されています)
fn main() {
let mut file = std::fs::File::open("example.msgpack")
.unwrap();
// ファイルの内容をbufに書き込む
let mut buf = Vec::new();
file.read_to_end(&mut buf);
let person: Person = zerompk::from_msgpack(&buf)
.unwrap();
}
これはBufReadやSeekに対して特化した実装を行うことで最適化できる見込みですが、今のところzerompkでは実装されていません。とはいえ効果はかなりありそうなので、近いうちに実装したいですね。
No Serde
先程のサンプルコードを見てのとおり、zerompkはSerdeの実装を利用せず、独自のtraitを用いてシリアライズ/デシリアライズを行います。
#[derive(FromMessagePack, ToMessagePack)] // これ
pub struct Point {
pub x: i32,
pub y: i32,
}
RustのシリアライザはほとんどがSerdeの上に乗っかっているため、これは不便に思えるかもしれません。しかし、ベストなパフォーマンスを求める上で、Serdeを切り捨てるのは割と理に適った判断です。
というのも、Serdeの生成コードは想像以上に複雑で、コンパイル時だけでなく実行時のパフォーマンスにも無視できないレベルの影響があります。実際に上のPointに対してSerde、zerompkが生成するコードを見ていきましょう。
Serde
#[doc(hidden)]
#[allow(
non_upper_case_globals,
unused_attributes,
unused_qualifications,
clippy::absolute_paths,
)]
const _: () = {
#[allow(unused_extern_crates, clippy::useless_attribute)]
extern crate serde as _serde;
#[automatically_derived]
impl _serde::Serialize for Point {
fn serialize<__S>(
&self,
__serializer: __S,
) -> _serde::__private228::Result<__S::Ok, __S::Error>
where
__S: _serde::Serializer,
{
let mut __serde_state = _serde::Serializer::serialize_struct(
__serializer,
"Point",
false as usize + 1 + 1,
)?;
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"x",
&self.x,
)?;
_serde::ser::SerializeStruct::serialize_field(
&mut __serde_state,
"y",
&self.y,
)?;
_serde::ser::SerializeStruct::end(__serde_state)
}
}
};
#[doc(hidden)]
#[allow(
non_upper_case_globals,
unused_attributes,
unused_qualifications,
clippy::absolute_paths,
)]
const _: () = {
#[allow(unused_extern_crates, clippy::useless_attribute)]
extern crate serde as _serde;
#[automatically_derived]
impl<'de> _serde::Deserialize<'de> for Point {
fn deserialize<__D>(
__deserializer: __D,
) -> _serde::__private228::Result<Self, __D::Error>
where
__D: _serde::Deserializer<'de>,
{
#[allow(non_camel_case_types)]
#[doc(hidden)]
enum __Field {
__field0,
__field1,
__ignore,
}
#[doc(hidden)]
struct __FieldVisitor;
#[automatically_derived]
impl<'de> _serde::de::Visitor<'de> for __FieldVisitor {
type Value = __Field;
fn expecting(
&self,
__formatter: &mut _serde::__private228::Formatter,
) -> _serde::__private228::fmt::Result {
_serde::__private228::Formatter::write_str(
__formatter,
"field identifier",
)
}
fn visit_u64<__E>(
self,
__value: u64,
) -> _serde::__private228::Result<Self::Value, __E>
where
__E: _serde::de::Error,
{
match __value {
0u64 => _serde::__private228::Ok(__Field::__field0),
1u64 => _serde::__private228::Ok(__Field::__field1),
_ => _serde::__private228::Ok(__Field::__ignore),
}
}
fn visit_str<__E>(
self,
__value: &str,
) -> _serde::__private228::Result<Self::Value, __E>
where
__E: _serde::de::Error,
{
match __value {
"x" => _serde::__private228::Ok(__Field::__field0),
"y" => _serde::__private228::Ok(__Field::__field1),
_ => _serde::__private228::Ok(__Field::__ignore),
}
}
fn visit_bytes<__E>(
self,
__value: &[u8],
) -> _serde::__private228::Result<Self::Value, __E>
where
__E: _serde::de::Error,
{
match __value {
b"x" => _serde::__private228::Ok(__Field::__field0),
b"y" => _serde::__private228::Ok(__Field::__field1),
_ => _serde::__private228::Ok(__Field::__ignore),
}
}
}
#[automatically_derived]
impl<'de> _serde::Deserialize<'de> for __Field {
#[inline]
fn deserialize<__D>(
__deserializer: __D,
) -> _serde::__private228::Result<Self, __D::Error>
where
__D: _serde::Deserializer<'de>,
{
_serde::Deserializer::deserialize_identifier(
__deserializer,
__FieldVisitor,
)
}
}
#[doc(hidden)]
struct __Visitor<'de> {
marker: _serde::__private228::PhantomData<Point>,
lifetime: _serde::__private228::PhantomData<&'de ()>,
}
#[automatically_derived]
impl<'de> _serde::de::Visitor<'de> for __Visitor<'de> {
type Value = Point;
fn expecting(
&self,
__formatter: &mut _serde::__private228::Formatter,
) -> _serde::__private228::fmt::Result {
_serde::__private228::Formatter::write_str(
__formatter,
"struct Point",
)
}
#[inline]
fn visit_seq<__A>(
self,
mut __seq: __A,
) -> _serde::__private228::Result<Self::Value, __A::Error>
where
__A: _serde::de::SeqAccess<'de>,
{
let __field0 = match _serde::de::SeqAccess::next_element::<
i32,
>(&mut __seq)? {
_serde::__private228::Some(__value) => __value,
_serde::__private228::None => {
return _serde::__private228::Err(
_serde::de::Error::invalid_length(
0usize,
&"struct Point with 2 elements",
),
);
}
};
let __field1 = match _serde::de::SeqAccess::next_element::<
i32,
>(&mut __seq)? {
_serde::__private228::Some(__value) => __value,
_serde::__private228::None => {
return _serde::__private228::Err(
_serde::de::Error::invalid_length(
1usize,
&"struct Point with 2 elements",
),
);
}
};
_serde::__private228::Ok(Point { x: __field0, y: __field1 })
}
#[inline]
fn visit_map<__A>(
self,
mut __map: __A,
) -> _serde::__private228::Result<Self::Value, __A::Error>
where
__A: _serde::de::MapAccess<'de>,
{
let mut __field0: _serde::__private228::Option<i32> = _serde::__private228::None;
let mut __field1: _serde::__private228::Option<i32> = _serde::__private228::None;
while let _serde::__private228::Some(__key) = _serde::de::MapAccess::next_key::<
__Field,
>(&mut __map)? {
match __key {
__Field::__field0 => {
if _serde::__private228::Option::is_some(&__field0) {
return _serde::__private228::Err(
<__A::Error as _serde::de::Error>::duplicate_field("x"),
);
}
__field0 = _serde::__private228::Some(
_serde::de::MapAccess::next_value::<i32>(&mut __map)?,
);
}
__Field::__field1 => {
if _serde::__private228::Option::is_some(&__field1) {
return _serde::__private228::Err(
<__A::Error as _serde::de::Error>::duplicate_field("y"),
);
}
__field1 = _serde::__private228::Some(
_serde::de::MapAccess::next_value::<i32>(&mut __map)?,
);
}
_ => {
let _ = _serde::de::MapAccess::next_value::<
_serde::de::IgnoredAny,
>(&mut __map)?;
}
}
}
let __field0 = match __field0 {
_serde::__private228::Some(__field0) => __field0,
_serde::__private228::None => {
_serde::__private228::de::missing_field("x")?
}
};
let __field1 = match __field1 {
_serde::__private228::Some(__field1) => __field1,
_serde::__private228::None => {
_serde::__private228::de::missing_field("y")?
}
};
_serde::__private228::Ok(Point { x: __field0, y: __field1 })
}
}
#[doc(hidden)]
const FIELDS: &'static [&'static str] = &["x", "y"];
_serde::Deserializer::deserialize_struct(
__deserializer,
"Point",
FIELDS,
__Visitor {
marker: _serde::__private228::PhantomData::<Point>,
lifetime: _serde::__private228::PhantomData,
},
)
}
}
};
zerompk
impl ::zerompk::ToMessagePack for Point {
fn write<W: ::zerompk::Write>(
&self,
writer: &mut W,
) -> ::core::result::Result<(), ::zerompk::Error> {
writer.write_array_len(2usize)?;
self.x.write(writer)?;
self.y.write(writer)?;
Ok(())
}
}
impl<'__msgpack_de> ::zerompk::FromMessagePack<'__msgpack_de> for Point {
fn read<R: ::zerompk::Read<'__msgpack_de>>(
reader: &mut R,
) -> ::core::result::Result<Self, ::zerompk::Error>
where
Self: Sized,
{
reader.increment_depth()?;
let __result = {
reader.check_array_len(2usize)?;
Ok(Self {
x: <i32 as ::zerompk::FromMessagePack<'__msgpack_de>>::read(reader)?,
y: <i32 as ::zerompk::FromMessagePack<'__msgpack_de>>::read(reader)?,
})
};
reader.decrement_depth();
__result
}
}
複雑なVisitorを生成するSerdeに比べ、zerompkの生成するコードは極めて単純です。これはどう見てもzerompkのほうが速そうでしょう。
また、副次的な効果として、生成コードが小さいためコンパイル時間やバイナリサイズの削減に繋がる効果もあります。
ゼロコピー
Rustで高速なコードを書く上でボトルネックとなりがちなのがコピー処理です。高いパフォーマンスを出すためには、可能な限りゼロコピーで処理していくことが重要になります。
とはいえ、rkyvなどの完全ゼロコピーを実現するシリアライザとは異なり、MessagePackでコピーを完全に排除することは困難です。というのもMessagePackの形式は可変長エンコーディングかつビッグエンディアンであり、Rustの構造体にそのままマッピングすることは困難です。
妥協案として、Serde同様に部分的なゼロコピーを実装しました。文字列型やバイナリ型を&str/&[u8]で受けることで、元データのスライスを直接借用することができます。
#[derive(ToMessagePack, FromMessagePack)]
pub struct NoCopy<'a> {
pub str: &'a str,
pub bin: &'a [u8],
}
fn main() -> Result<()> {
let value = NoCopy {
str: "hello",
bin: &[0x01, 0x02, 0x03],
};
let msgpack = zerompk::to_msgpack_vec(&value)?;
let value: NoCopy = zerompk::from_msgpack(&data)?;
}
Mapデシリアライズの最適化
MessagePackにおけるオブジェクトの表現にはArrayとMapの2種類があります。
例えば以下のような構造体の場合。
#[derive(ToMessagePack, FromMessagePack)]
struct Point {
x: i32,
y: i32,
}
fn main() {
// これをシリアライズしたい
let p = Point {
x: 1,
y: 2,
};
}
これをArrayで表現すると
+--------+------+------+
| FixArr | x | y |
| 0x92 | 0x01 | 0x02 |
+--------+------+------+
Mapで表現すると
+--------+-----+-------+-----+-------+
| FixMap | key | value | key | value |
| 0x82 | "x" | 0x01 | "y" | 0x02 |
+--------+-----+-------+-----+-------+
こうなります。当然Arrayの方がバイナリサイズやパフォーマンスの面で有利ですが、わかりやすさやバージョニング耐性ではMapに軍配が上がるでしょう。
どっちの形式であれシリアライズは愚直にやればよし、Arrayのデシリアライズも順に読み取るだけなんですが、問題はMapのデシリアライズです。当然バイナリ上のキーの順序は不定であるため、順番に読み取るだけでは足りず、キーを都度検索するコストが生じます。
これを実装すると以下のようになるでしょう。
impl<'a> FromMessagePack<'a> for Point {
fn read<R: Read<'a>>(&self, reader: &mut R) -> Result<Point> {
let mut x: Option<i32> = None;
let mut y: Option<i32> = None;
let len = reader.read_map_len()?;
for _ in 0..len {
let key = reader.read_string()?;
match &key {
"x" => {
x = Some(reader.read_i32()?);
},
"y" => {
y = Some(reader.read_i32()?);
},
unknown => {
return Err(Error::UnknownKey(unknown.to_owned()));
}
};
}
Ok(Point {
x: x.ok_or(Error::KeyNotFound("x".into()))?,
y: y.ok_or(Error::KeyNotFound("y".into()))?,
})
}
}
Arrayとは異なり、キーの探索のために都度matchによる分岐が発生します。これはMapとしてシリアライズする都合上、どうしても避けられないコストになるでしょう。
UTF-8の検証をバイパスする
とはいえこれでも十分な速度は出るんですが、せっかくなのでもっと高速化しましょう。まず着目すべきはread_string()の部分です。
let key = reader.read_string()?;
zerompk::Read::read_string()の戻り値はStringではなくCow<'a, str>です。そのため、元ソースがスライスである場合(std::io::Readでない場合)に関しては、ここでStringによるアロケーションが行われることはありません。
しかし、まだ高速化の余地があります。&[u8]から&strを構築する際にはバイト列が正しいUTF-8であることを検証するコストがかかります。しかし、今回はそもそもバイト列をUTF-8であるキーと比較する(一致しなければエラーになる)だけなので、わざわざここで検証を挟む必要がありません。
というわけで、文字列をstrにせずにそのままu8バイト列として受け取るzerompk::Read::read_string_bytes()を用意しました。
let key_bytes = reader.read_string_bytes()?;
match &key_bytes {
b"x" => ...
b"y" => ...
_ => return Err(...)
};
これで不要なUTF-8チェックをバイパスできます。
オートマトンベースの文字列探索
この程度のサイズ、フィールド名の短さであればこれで十分でしょう。しかし、フィールド数が10や20に増えた場合、match内部のケースが膨れ上がることになります。
let key_bytes = reader.read_string_bytes()?;
// ここの処理が膨れ上がる
match &key_bytes {
b"foo_bar_baz1" => ...
b"foo_bar_baz2" => ...
...
_ => return Err(...)
};
Rustの文字列に対するmatchの実装は意外と愚直で、これをLLVM IRにコンパイルすると、
- 文字列の長さでswitch
- 各caseごとにif-else if-else相当のコードで
bcompを叩いて比較
という形でマッチングを行います。一応bcomp自体が高速なので普通はこれで十分ですが、大量のケースや長い文字列が含まれている場合は思ったほど速度が出ません。
また、この手の文字列マッチングに関しては完全ハッシュ関数(PHF)を用いたハッシュテーブルで処理する方法もありますが、構造体のフィールド数は基本的にそこまで多くないため、この程度ではハッシュ算出のコストが上回ってしまいます。
というわけで今回はオートマトンベースの探索を採用します。これはMessagePack for C#でも用いられている最適化手法で、8バイト単位のトライ木でキーのルックアップを行うものです。

引用した図のように文字列のUTF8バイナリを8バイト単位でu64に変換し、数回の整数比較でマッチさせます。実際の生成コードは以下のような感じ。
生成コード
#[msgpack(map)]
pub struct TestStruct {
pub field_0: i32,
pub field_1: String,
pub field_2: bool,
pub field_3: Vec<Point>,
}
impl ::zerompk::ToMessagePack for TestStruct {
fn write<W: ::zerompk::Write>(
&self,
writer: &mut W,
) -> ::core::result::Result<(), ::zerompk::Error> {
writer.write_map_len(4usize)?;
writer.write_string("field_0")?;
self.field_0.write(writer)?;
writer.write_string("field_1")?;
self.field_1.write(writer)?;
writer.write_string("field_2")?;
self.field_2.write(writer)?;
writer.write_string("field_3")?;
self.field_3.write(writer)?;
Ok(())
}
}
impl<'__msgpack_de> ::zerompk::FromMessagePack<'__msgpack_de> for TestStruct {
fn read<R: ::zerompk::Read<'__msgpack_de>>(
reader: &mut R,
) -> ::core::result::Result<Self, ::zerompk::Error>
where
Self: Sized,
{
reader.increment_depth()?;
let __result = {
reader.check_map_len(4usize)?;
let mut __slot_field_0: ::core::option::Option<i32> = ::core::option::Option::None;
let mut __slot_field_1: ::core::option::Option<String> = ::core::option::Option::None;
let mut __slot_field_2: ::core::option::Option<bool> = ::core::option::Option::None;
let mut __slot_field_3: ::core::option::Option<Vec<Point>> = ::core::option::Option::None;
for _ in 0..4usize {
let __key_bytes = reader.read_string_bytes()?;
let __key_bytes = __key_bytes.as_ref();
let __key_index = (|| -> ::zerompk::Result<usize> {
let __matched_idx: usize = match __key_bytes.len() {
7usize => {
let __key_chunk_0: u64 = ((u32::from_le_bytes(unsafe {
*(__key_bytes.as_ptr().add(0usize) as *const [u8; 4])
}) as u64)
| ((u16::from_le_bytes(unsafe {
*(__key_bytes.as_ptr().add(4usize) as *const [u8; 2])
}) as u64) << 32) | ((__key_bytes[6usize] as u64) << 48));
match __key_chunk_0 {
13615683802065254u64 => 0usize,
13897158778775910u64 => 1usize,
14178633755486566u64 => 2usize,
14460108732197222u64 => 3usize,
_ => usize::MAX,
}
}
_ => usize::MAX,
};
if __matched_idx != usize::MAX {
Ok(__matched_idx)
} else {
{
let __unknown_key = match ::core::str::from_utf8(
__key_bytes,
) {
Ok(s) => s.into(),
Err(_) => "<invalid-utf8>".into(),
};
Err(::zerompk::Error::UnknownKey(__unknown_key))
}
}
})()?;
match __key_index {
0usize => {
if __slot_field_0.is_some() {
return Err(
::zerompk::Error::KeyDuplicated("field_0".into()),
);
}
__slot_field_0 = ::core::option::Option::Some(
<i32 as ::zerompk::FromMessagePack<
'__msgpack_de,
>>::read(reader)?,
);
}
1usize => {
if __slot_field_1.is_some() {
return Err(
::zerompk::Error::KeyDuplicated("field_1".into()),
);
}
__slot_field_1 = ::core::option::Option::Some(
<String as ::zerompk::FromMessagePack<
'__msgpack_de,
>>::read(reader)?,
);
}
2usize => {
if __slot_field_2.is_some() {
return Err(
::zerompk::Error::KeyDuplicated("field_2".into()),
);
}
__slot_field_2 = ::core::option::Option::Some(
<bool as ::zerompk::FromMessagePack<
'__msgpack_de,
>>::read(reader)?,
);
}
3usize => {
if __slot_field_3.is_some() {
return Err(
::zerompk::Error::KeyDuplicated("field_3".into()),
);
}
__slot_field_3 = ::core::option::Option::Some(
<Vec<
Point,
> as ::zerompk::FromMessagePack<
'__msgpack_de,
>>::read(reader)?,
);
}
_ => {
::core::panicking::panic(
"internal error: entered unreachable code",
)
}
}
}
let field_0 = __slot_field_0
.ok_or_else(|| ::zerompk::Error::KeyNotFound("field_0".into()))?;
let field_1 = __slot_field_1
.ok_or_else(|| ::zerompk::Error::KeyNotFound("field_1".into()))?;
let field_2 = __slot_field_2
.ok_or_else(|| ::zerompk::Error::KeyNotFound("field_2".into()))?;
let field_3 = __slot_field_3
.ok_or_else(|| ::zerompk::Error::KeyNotFound("field_3".into()))?;
Ok(Self {
field_0: field_0,
field_1: field_1,
field_2: field_2,
field_3: field_3,
})
};
reader.decrement_depth();
__result
}
}
人間が読むにはかなりつらいコードになってますが、要は上のオートマトンをコンパイル時にderiveマクロで構築している訳です。これによりMapのデシリアライズをmatchよりも高速に行うことが可能になっています。
セキュリティ
シリアライザを作る際に考慮しなければならない事項として、脆弱性に関するものがあります。外から流れてくるバイナリは100%安全とは限らないので、デシリアライズの際にはシリアライザ側でも何らかの対策を講じておく必要があるわけです。
と言ってもzerompkは常に厳格な型を要求するので、Javaや.NET、JavaScript等でありがちな変な型をデシリアライズされる、というような脆弱性は起こりえません。.NETだとBinaryFormatterがバイナリに型情報を埋め込む都合上この脆弱性が含まれていて、結果として.NET 9で実装が消滅した、ということがあったりなかったり。
また、その他の攻撃としては、小さなバイナリに巨大なサイズを指定した配列ヘッダを仕込んだり、過剰にネストしたオブジェクトを含めてコールスタックを枯渇させる、というようなものがあります。これに関してはバッファサイズが足りているかを先に検証したり(愚直にチェックを通さないままVec::with_capacity()はNG!)、規定数以上のdepthに到達したらエラーを返したり、といった形の対策を行っています。
もちろん、データの中身そのものが不正な場合の対策はシリアライザ側では行えないので、そこはアプリケーション側で認証を行うようにしましょう。
まとめ
zerompk、元々はRustの最適化を学ぶための習作として何か作ろうと考えていて、ちょうどシリアライザの実装を1からやったことはなかったな、と思い立ったのが作るきっかけだったりします。とはいえ機能面もちゃんと揃っているので、十分実用に足る出来にはなったんじゃないでしょうか。
最適化やセキュリティ周りに関してはMessagePack for C#を参考に実装した部分が多いです。MessagePack for C#の実装はとにかく良く出来ていてすごい。ただやはりバイナリのデコードのようなコードを最適化するのはC#よりRustの方が楽ですね。型システムが強いとGenericsで書くのがとにかく楽で良い感じです。
というわけでかなりいい感じになっているので、是非是非使ってみてください...!
Discussion