C#に置ける日付のシリアライズ、DateTimeとDateTimeOffsetの裏側について

C# Advent Calendar 2016の記事になります。何気に毎年書いてるんですよねー。今年は、つい最近ZeroFormatterというC#で最速の(本当にね!)シリアライザを書いたので、その動的コード生成部分にフォーカスして、ILGenerator入門、にしようかと思ってました。ILGeneratorでIL手書き、というと、黒魔術!難しい!と思ってしまうけなのですが、実のところ別に、分かるとそれほど難しくはなくて(面倒くさい&デバッグしんどいというのはある)、しかし同時にILGeneratorで書いたから速かったり役に立ったり、というのもなかったりします。大事なのは、どういうコードを生成するのかと、全体でどう使わせるようなシステムに組み上げるのか、だったり。とはいえ、その理想的なシステムを組むための道具としてILGeneratorによるIL手書きが手元にあると、表現力の幅は広がるでしょう。

シリアライザ作って思ったのは、Jilは(実装が)大変良くできているし、SigilはIL生成において大いに役立つ素晴らしいライブラリだと思ってます。まぁ、そういうの作る時って依存避けたいので使わなかったけれどね……。

みたいなイイ話をしようと思っていたんですが、ちょっと路線変更でDateTimeについてということにします。えー。まぁいいじゃないですか、DateTimeだって深いですし、IL手書きなんかよりずっと馴染み深いではないですか。役立ち役立ち。

DateTimeとはなんぞやか

シリアライズの観点から言うと、ulongです。DateTimeとはulongのラッパー構造体というのが実体です。ulongとは、Ticksプロパティのことを指していて、なので例えばDayを取ろうとすれば内部的にはTicksから算出、AddHoursとすればhoursをTicksに変換した後に内部的なulongを足して、新しい構造体を返す。といった形に内部的にはなっています。それぞれのオペレーションは除算をちょっとやる程度なので、かなり軽量といってもいいでしょう。

つまり、DateTimeとはなんぞやかというのは、Ticksってなんやねん、という話でもある。

Ticksとは、100ナノセカンド精度での、0が0001/01/01 00:00:00から、最大が9999/12/31 23:59:59.999999までを指す。ほほー。

// 0001-01-01T00:00:00.0000000
new DateTime(ticks: 0).ToString("o");
// 0001-01-01T00:00:00.0000001
new DateTime(ticks: 1).ToString("o");

// 3155378975999999999
DateTime.MaxValue.Ticks

DateTimeにはもう一つ、Kindという情報も保持しています。KindはUtcかLocalか謎か(Unspecified)の三択。

public enum DateTimeKind
{
    Unspecified = 0,
    Utc = 1,
    Local = 2
}

ふつーにDateTime.Nowで取得する値は、Localになっています、ので日本時間である+9:00された値が取れます。さて、このKindは内部的にはどこに保持されているかというと、Ticksと相乗りです!ulong、つまり8バイト、つまり64ビットのうち62ビットをTicksの表現に、残りの2ビットでKindを表現しています。なんでそういう構造になっているかといえば、まぁ節約ですね、メモリの節約。まー、コアライブラリなのでそういう気の使い方します、的な何か。

Ticksプロパティ、Kindプロパティはそれぞれ内部データを脱臭した値が出てくるので、そうしたTicks, Kindが相乗りした内部データを取りたい場合はToBinaryメソッドを使います。復元する場合は、FromBinaryです。

// Ticks + Kind(long, 8byte)
var dateData = DateTime.Now.ToBinary();
var now = DateTime.FromBinary(dateData);

これで8バイトでDateTimeの全てを表現できるので、これが最小かつ最速な手法になります。あまり使うこともないと思いますが。

さて、当然ZeroFormatterはそうしたToBinaryで保持してるんだよね!?というと、違います!seconds:long + nanos:intという12バイト使った表現(秒+ナノ秒)にしています。これはProtocol Buffersの表現を流用していて、うーん、一応クロスプラットフォーム的にはそのほうがいいかな、みたいな(でも今考えると別にTicksで何が悪い、って気はする……失敗した……)。そして、Kindは捨てています。シリアライズ時にToUniversalTimeでUTCに変換し、そのUTCの値のみシリアライズしています。

で、Kindは、私は捨てていいと思ってます。一応MSDNのDateTime、DateTimeOffset、TimeSpan、および TimeZoneInfo の使い分けというドキュメントにもありますが

DateTime データを保存または共有する際、UTC を使用する必要があり、DateTime 値の Kind プロパティを DateTimeKind.Utc に設定する必要があります。

UTCかLocalか、なんていうだけの二値はシリアライズに全く向いてないです。それだったらTimeZoneも保存しないと意味がない。アメリカで復元したらどうなんねん、みたいな。なのでシリアライズという観点で見るとKindはナンセンス極まりないです。これはDateTimeの設計が悪いって話でもあるんですが(後述するDateTimeOffsetがDateTimeのラッパーみたいな感じになってますけれど、本質的にはその逆であるべきだと思う)、その辺(初期の.NETのクラスはどうしても微妙にしょっぱいところがある)はshoganaiんで、受け入れるんだったらKindは無視。これが鉄板。

DateTimeOffset

Kindを無視するのはいいけれど、時差の保存は欲しいよね、という時の出番がDateTimeOffset。これは内部的には ulong(DateTime) + short(オフセット分) の2つの値で保持しています。まんま、DateTimeとOffset。DateTime.NowとDateTimeOffset.Nowって同じような値が帰ってくるし違いはなんなんやねん、というと、DateTimeOffsetはローカル時間といったKindじゃなくて、明確に内部的に+9時間というオフセットを持っているということです。

ZeroFormatterでシリアライズする際は、こちらはオフセットも保存していて、 seconds:long + nanos:int + minutes:short の14バイトの構成です。

ZeroFormatter上では明確にDateTimeとDateTimeOffsetは違うものとして取り扱ってるわけですが、よくあるDateTimeをToString("o")した場合って(んで、JSONなんかに乗せる場合って)

// 2016-12-07T03:19:23.7683110+09:00
DateTime.Now.ToString("o");
// 2016-12-07T03:19:23.7713117+09:00
DateTimeOffset.Now.ToString("o");

と、いうふうに、完全に一緒なわけです。というか、むしろこれはDateTimeの(文字列への)シリアライズをDateTimeOffsetとして表現している、とも言えます。まぁ、そのほうが実用上は親切ではある。が、これはDateTimeもDateTimeOffsetも区別してない(stringで表現)からっていうことであって、決してKindもシリアライズしているということではないということには注意。そして明確にDateTimeとしてDateTimeOffsetを違うものとして扱うなら(ZeroFormatterの場合)、良くも悪くもこういう表現はできないんだなぁ。不便だけどね。

基本的にDateTimeOffset、のほうが使われるべき正しい表現だと思うんですが、.NETのクラス設計上、DateTimeのほうが簡潔(だし内部構造的にもDateTimeOffsetはDateTime+αという形)で短い名前(名前超大事!)である以上、DateTimeの天下は揺るがないでしょう。残念なことにDateTimeOffsetの登場が.NET 2.0 SP1からだということもあるし。DateTimeOffsetがDateTimeで、DateTimeがLocalDateTimeだったら話は変わってくるでしょうけれど(そしてそんな構造だったらきっとLocalDateTimeは使われない)、まぁ変わらないものは変わらないです。まぁ保存用途ならUTCが良いと思うんで、現代的な意味では逆にDateTimeOffsetの出番はより減ってきたとも言える。データがクラウドに保存されて世界各国で共有されるとか当たり前なので、保存はUTC、表示時にToLocalTimeのほうが合理的。Kindって何やねん、と同じぐらいOffsetって何やねん、みたいな。

まぁLocalDateTime, ZonedDateTime, OffsetDateTimeという3種で表現というJava8方式が良いですよねということになる。

NodaTime

日付と時間に関しては、TimeZoneやCalendarなど、真面目に扱うとより泥沼街道を突っ走らなければならないわけですが、いっそ.NET標準のクラスを「使わない」という手もあります。NodaTimeは良い代替で、Javaの実質標準のJodaTime(後にJava8 Date API)の移植ではありますが、製作者がJon Skeet(Stackoverflow回答ランキング世界一位, Microsoft MVP, google, C# in Depth著者)なので、ありがちなJava移植おえー、みたいなのでは決してないのが一安心。

こういった標準クラスを置き換える野良ライブラリはシリアライズ出来ないのが難点で、そうしたシリアライズの表象でだけDateTime/DateTimeOffsetに置き換えるというのはよくあるパターンですが面倒くさくはある。シリアライザの拡張ポイントを利用してネイティブシリアライズ出来るようにするのが良い対応かなー、というのはあります。NodaTimeは標準でJson.NETに対応した拡張ライブラリが用意されているというところも、(当たり前ですが)わかってるなー度が高くていいですね。ZeroFormatterも拡張ポイントを持っているので、必要な分だけ手書きして対応させれば、まぁ、まぁ:)

まとめ

DateTimeOffsetも可愛い子ではある。時に使ってあげてください。というわけで、次のアドベントカレンダーは@Marimoiroさんです!

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive