読者です 読者をやめる 読者になる 読者になる

F# の型をZeroFormatterで扱うための拡張ライブラリを作った

本日もZeroFormatterネタです。

年始の記事で

欲しい人がそのうち作るでしょう

などと書いていたら見事にブーメランでした。 欲しくなったというよりはF#erとしての意地(謎)という感じですが。

本家ライブラリだけでどこまで書けるのか?

primitiveな型やコレクションは普通に動きます。 .NETですからね、そりゃそうだ。

問題はF#固有の存在やObjectです。 C#とは書き味が異なるので検証してみましょう。

試したバージョンはZeroFormatter 1.6.2です。

Measure

[<Measure>]
type kg

let bytes = ZeroFormatterSerializer.Serialize(input)
ZeroFormatterSerializer.Deserialize<int<kg>>(bytes)

これはtype eraseされるので普通に動きます。

Object

[<ZeroFormattable>]
type MyObject() =

  abstract member MyProperty1: int with get, set
  [<Index(0)>]
  default val MyProperty1 = 0 with get, set

  abstract member MyProperty2: int64 with get, set
  [<Index(1)>]
  default val MyProperty2 = 0L with get, set

  abstract member MyProperty3: float32 with get, set
  [<Index(2)>]
  default val MyProperty3 = 0.f with get, set
  • virtualを要求するためabstract memberでプロパティを定義する
  • デフォルト実装のほうにIndexAttributeをつける必要がある
    • abstractなほうにつけると実行時エラー
  • ここではvalを使ったけど別に他の書き方でも問題はない

proectedがないからどうすんべ、みたいな問題は残りますが気にしてはいけません。

Structや.NET的なもの

.NETに存在する型やStructはだいたいおんなじ感じです。

tupleがStruct?で実装されていることに面食らうかもしれません。 F#だとtupleがnullになることなんてまず考えませんからねぇ…。

FSharp由来の型

実行時エラーになります。

  • Set, MapはICollection判定されるが、これらはnew ()制約を持たないため実行時に制約違反エラー
  • F#のlistはマッチする型が見つからない?
  • レコードはStructではない && 0引数のコンストラクタももたないのでObjectの条件を満たさず実行時エラー
  • 判別共用体は当然マッチする型がない
  • unitも(ry

まあ、BuiltInのものだけでは限界があるわけです。

FSharp用の拡張

https://github.com/pocketberserker/ZeroFormatter.FSharpExtensions

というわけで、上記の様々な問題を解決するための拡張ライブラリを作りました。 ZeroFormatter.FSharp.ZeroFormatterSerializerを使うとF#な型もシリアライズ、デシリアライズできるようになります。

基本姿勢としてF#の型はStage1のWireFormatの域をはみ出さないように作ってあるので、F#の判別共用体をシリアライズしたバイナリを他言語でUnionとしてデシリアライズする、なんてことも可能です。

判別共用体とレコードはeager-evaluationです。 virtualにできないのでObject的なデシリアライズができないんですよねー。 あとこの2つはStructなフォーマットに落とし込んでいる関係でVersioningをサポートしていません。 Versioningを使いたいならObject+アクティブパターンでどうにかするのが吉です。 マイクロサービス的にV1とV2のサーバをぽんぽこ立ち上げて投げ分ける、という手段をとれる人はVersioningにこだわらなくても良い、のかなぁ…ヨクワカリマセン。

注意点としてはoptionを使わない限りnull safeにならないことですね。 バイナリ仕様上、nullが返ってくるのは避けられないことなのです。 うっかりするとレコードや判別共用体にnullが突っ込まれるので、nullなものが飛んできそうならoptionで包んでください。

あと、ZeroFormatter 1.6.2の時点では判別共用体と同様のUnionを定義してもそのままだとシリアライズ、デシリアライズできません。 原因は把握していて、structの場合引数なしのコンストラクタがなくnullになるので落ちます。 自力でその型専用のFormatterを定義してregisterすれば回避できるかと思いきや、組み込みのFormatterが優先されてしまうため先ほどと同じエラーに遭遇します(プルリクはだしたhttps://github.com/neuecc/ZeroFormatter/pull/57)。 最終的にResolverでひと工夫すれば実行できるようになりますが、余計なFormatterを1つ経由しないといけないのでつらい…改善されることを祈りましょう。

内部実装

実装はフルC#です、はい。 ミュータブルばりばりな予感がしたのでF#で実装すること自体が頭になかったですね。

判別共用体の実装はIL以外の部分が地味に面倒でした。 ちなみにデシリアライズだけボクシングが挟まるので遅い、かも。 私の力ではFSharpValue.MakeUnionを使って生成する方法しか無理だったのです…。

なおILGeneratorを使ったコードを初めて書きましたが、まぁ、手本とILSpyがあればあとはpushしてpopするだけなので(おい)、小さめのコードならそんなに苦労はないですね。 意味不明な実行時例外が飛んでくるなんてよくあることなので気にならなかった(?)です。 テストが通らなかった時は一度落ち着いてから脳内stackを駆使するに限る。

Analyzerは?

Visual Studioで消耗したくない…はともかくとして、今のところ労力に見合わないので実装予定はありません。 IDEでIndex重複をエラーにするとか自動でZeroFormattableな型に変更するとかすべきなのでしょうけどね…そういうのは実際に利用する機会がでてきたときにでも作ろうかと。

Unity対応とかコードジェネレータは?

zfcに手を入れるのは大変そうだし(仮にやっても破壊的変更しすぎてマージしてもらえる気がしない)、かといって1から作るのもなぁ…。 ZeroFormatterなバイナリASTを用意してpluginと通信する、とか漠然と考えてはみたものの、無限に時間をもっていかれそうな気がしたのでいったん考えることをやめました。

とはいえうーん、他言語との通信を考えるとやっぱりジェネレータが必要な気がする…。 msgpackのデメリットの一つは(実質)標準IDLがないことに起因してジェネレータが存在しないことだと思っています。 ZeroFormatterは(C#という)スキーマがあるのでそのあたり事情がことなるけれど、現状は言語ごとに型を手書きするしかないので2度手間だし間違えそう、とか考えるとあまり状況は変わらないのかなと。

とりあえずたたき台を作ってみるべきなのかなぁ…悩まし。

おわりに

ZeroFormatter系ライブラリはこれで3つ目。 なんというか、バイナリシリアライザはほんと色んな意味で訓練になります、はい。