きしだのはてな このページをアンテナに追加 RSSフィード

2013-09-17(火) Java8日付時刻APIの使いづらさと凄さ

[]Java8日付時刻APIの使いづらさと凄さ 09:05 Java8日付時刻APIの使いづらさと凄さを含むブックマーク

いままでのJavaでは、日付時刻を扱おうとするとめんどくさい割に非常に低機能でした。

Java8では、新たに日付時刻APIが導入され、めんどくささが増しつつ非常に高機能になりました。

(以降、Java8で導入された日付時刻APIを単に「日付時刻API」と表します)


もちろん、慣れてきて、ちょっとしたサポートメソッドを用意してやれば、結構使いやすいのですが、それは「このAPIは使いやすい」という評価にはなりません。

つまり日付時刻APIは、慣れないとぜんぜんわからないし、サポートメソッドがないと面倒なコードが必要ということです。


いろいろあってよくわからない

日付時刻では、時点を扱うInstantや期間を扱うPeriod、時間量をあらわすDurationなど多くのクラス・インタフェースが導入されています。

これらは、IDEの補完でAPIを探りながら機能を推測すれば、それなりにドキュメントなしで使うことができます。

けど、基本の日付時刻をあらわすクラスが何通りかあって、この使い分けはカンではちょっと難しいです。


まとめるとこんな感じです。

クラス 用途 部分クラス
LocalDateTime日付時刻をあらわすLocalDate/LocalTime
OffsetDateTime時差つきの日付時刻をあらわすOffsetTime
ZonedDateTimeタイムゾーンつきの日付時刻をあらわすナシ
JapaneseDate平成・昭和など日本の暦をあらわすJapaneseDateのみ

LocalDateTimeは、時差などの情報を持たない、単に何年何月何日何時何分何秒という情報を保持します。

OffsetDateTimeと、ZonedDateTimeの違いがわかりにくいです。

OffsetDateTimeは単純に標準時からの時差がついた日付時刻を扱います。そして、ZonedDateTimeは、タイムゾーンとして指定した地域での、夏時間などの補正も含めた日付時刻を扱います。

おそらく、アプリケーションではOffsetDateTimeを使うことはなく、ZonedDateTimeを使うことになると思います。

こういった使い分けは、なかなか浸透しないのではないかと思います。


ここで問題なのは、OffsetDateTimeかZonedDateTimeなど時差情報を持ったオブジェクトを渡すべきところに、LocalDateTimeのオブジェクトを渡してしまうと、時差情報がないという実行時エラーになるというところです。

部分的に動くからLocalDateTime使ってたけど統合しようとしたら例外が出て、あとからZonedDateTimeに置き換えるということが起きそうです。また、そういったことに気づかず地雷化する事例もあるかもしれません。


あと、日付時刻APIのオブジェクトは基本immutableで、オブジェクトの内容は変わらないのですが、このことで

public LocalDateTime add3Hours(LocalDateTime ldt){
  ldt.plusHours(3);//3時間すすめる
  return ldt;
}

のように、3時間すすめたように見えるけど、実際はなんもしてない、ってことでハマる人も多そうです。


旧来のDateとの相互変換が面倒

Javaはすでに10年以上も使われているので、旧来のjava.util.Dateを使う処理が多く書かれています。

そうすると、java.util.Dateと日付時刻APIでの相互変換を行う場面が多くなるはずです。

でも、日付時刻APIでは、

Date d = new Date();
LocalDateTime ldt = new LocalDateTime(d);

のように手軽に変換することはできません。


逆に

LocalDateTime ldt = LocalDateTime.now();
Date d = ldt.toDate();

のように一発変換のメソッドもありません。


とりあえず、そもそもDateと相互変換したいときには、LocalDateTimeではなく、OffsetDateTimeかZonedDateTimeを使うほうがいいです。OffsetDateTimeはあまりアプリケーションで使わないと思うので、Dateと相互変換する場合はZonedDateTimeを使うことになるでしょう。

Dateからの変換はこのようになります。

Date d = new Date();
ZonedDateTime zdt = ZonedDateTime.ofInstant(d.toInstant(), ZoneId.systemDefault());

めんどうですね。


ZonedDateTimeの場合は、次のようにも書けます。

Date d = new Date();
ZonedDateTime zdt = d.toInstant().atZone(ZoneId.systemDefault());

ちょっと楽です。

OffsetDateTimeの場合は、atZoneメソッドではなくてatOffsetメソッドですがZoneOffsetの取得が面倒です。


APIを見てると次のように書ける気がするかもしれませんが、爆死します。

Date d = new Date();
ZonedDateTime zdt = ZonedDateTime.from(d.toInstant());

たぶん、補完みながら初めて使う人は、一度は経験することでしょう。


逆の変換は次のようになります。

Date rd = Date.from(zdt.toInstant());

LocalDateTimeからDateへの変換は、なんか血をみます。

LocalDateTime ldt = LocalDateTime.now();
Date d = Date.from(ldt.toInstant(ZoneId.systemDefault().getRules().getOffset(ldt)));

ldtが2回でてくるあたり。


一度ZonedDateTimeに変換したほうがいいでしょう。

LocalDateTime ldt = LocalDateTime.now();
ZonedDateTime zdt = ldt.atZone(ZoneId.systemDefault());
Date rd = Date.from(zdt.toInstant());

こんな感じで、Dateとの相互変換には慣れが必要です。引数省略したらZoneId.systemDefault()を使うようになってればいいのに。


※ 2015/7/21 追記

DateからLocalDateTimeへの変換は次のように書けます。

Date d;
LocalDateTime ldt = LocalDateTime.ofInstant(d.toInstant(), ZoneId.systemDefault());

Java8 日付時刻APIのちょっとえらいところ

タイムゾーンと時差が別れていることから推測できるように、日付時刻APIでは各地域の夏時間などの情報をもっています。

で、タイムゾーンを扱うZoneIdには、固定時差かどうかを示すフラグがあるのですが、次のようにするとfalseが表示されました。

System.out.println(ZoneId.of("Asia/Tokyo").getRules().isFixedOffset());

日本には夏時間は導入されていないので、当然trueになるかと思ったんですが、falseがでてきて、「ふぁっ?」ってなりました。

で、そういえば戦後に夏時間がどうのという話があった気がすると思って、実際にどうなってるか表示してみました。

ZoneId.of("Asia/Tokyo").getRules().getTransitions().forEach(System.out::println);

そうすると次のようになりました。

Transition[Overlap at 1888-01-01T00:18:59+09:18:59 to +09:00]
Transition[Gap at 1948-05-02T02:00+09:00 to +10:00]
Transition[Overlap at 1948-09-11T02:00+10:00 to +09:00]
Transition[Gap at 1949-04-03T02:00+09:00 to +10:00]
Transition[Overlap at 1949-09-10T02:00+10:00 to +09:00]
Transition[Gap at 1950-05-07T02:00+09:00 to +10:00]
Transition[Overlap at 1950-09-09T02:00+10:00 to +09:00]
Transition[Gap at 1951-05-06T02:00+09:00 to +10:00]
Transition[Overlap at 1951-09-08T02:00+10:00 to +09:00]

ぐぐってみると、たしかに1948年〜1951年の間、日本で夏時間が実施されていたようです。

夏時間:日本におけるサマータイム - Wikipedia


なんかえらいなーと思いました。


tz databaseを使ってるんじゃないか、という指摘がありました。タイムゾーンを管理するデータベースです。

tz database - Wikipedia

こういうのがあるんですね。

ちゃんとこのような仕組みを標準APIとして持つのは(そしてメンテナンスしていくというのは)すごいなと思いました。

btnrougebtnrouge 2013/09/17 11:38 11月のJJUG CCCでこの辺のことをしゃべる予定ですが、事前予告的な言い訳をいくつか。一応JSR 310の末席として、様々な不都合、お詫び申し上げます。

言い訳1:
Date and Time API(JSR 310)はjava.util.Dateとjava.util.Calendar系を潰すつもりで開発を始めているので、特にDateとの互換性は意図的に下げられています。Instant経由での相互変換も、ひどいことに最初期の実装には存在すらしませんでした。

言い訳2:
LocalDateTime系は時差情報を持たないので、時刻の起点が定まっていない(システム依存)という特徴があります。一方でInstantとjava.util.Dateは暗黙的にUTCという時差情報をもっており、時刻の起点が定まっています。これがLocalDateTime.toInstantなるメソッドがない理由です(実は存在していた時期もありますが…)。LocalDateTime→Date変換は、手抜きで良ければこんな感じで。

Date d = Date.from(LocalDateTime.now().atOffset(ZoneOffset.ofHours(9)).toInstant());

メソッドチェーンが使えるので、例えばLocalDate→LocalDateTime→OffsetDateTime→Dateのような変換も1行でできます。見やすいかどうかは別として。
なお、EDRの時はDateのコンストラクタにInstantを取るものが存在していました。個人的にはそちらの方が好きでした。

言い訳3:
OffsetDateTimeとZonedDateTimeの使い分けは、正直微妙です。ただ、実行効率はOffsetDateTimeの方が余計な計算をしない分だけ良好です。日本のように夏時間が存在しない地域ではOffsetDateTimeを使った方がパフォーマンスの面で有利です。
もう一つ、JSR 310対応のJAXBのカスタムマーシャラを作成する場合、XMLGregorianCalendarとの変換を実装しますが、XMLGregorianCalendarは時差情報をタイムゾーンではなくUTCからの時差で表すため、最終的にはOffsetDateTimeとの相互変換になります。LocalDateTimeやZonedDateTimeを対応させる場合は、カスタムマーシャラ内でOffsetDateTimeに変換しなければなりません。
ということで、OffsetDateTime/OffsetTimeもそれなりに使いどころはあります。OffsetDateは昨秋のJJUG CCC時点は存在したものの、気が付いたら消滅していました。

言い訳4:
JSR 310は事実上Stephen Colebourneの独裁体制で、他の人間の意見が通るのはStephenがテンパっていて周りに丸投げしたときだけ。ということでAPI全体に彼の思想というか信念というか、そういったものが染みついています。数々の不都合はその辺から来ているものです。重ね重ねお詫び申し上げます。
※ceroさんにもJSR 310のフォーマットの件で以前謝ったような…

nowokaynowokay 2013/09/17 13:24 最初はDateとの変換のできなさに、かなり絶望したのを覚えています。というか、開発中だと信じて、そのときは触るのをやめました。
そのころに比べたら使いやすくなりましたね。よく見たら、InstantにatZone/atOffsetなるメソッドもできてるし。
ZoneId.systemDefault()をデフォルトで指定してくれる便利メソッドはほしいものです。

でもまあ、そういうプログラムインタフェースはどうにかなるとして、ちょっとしたミス(慣れないと意味がわからないところ)で例外爆発しまくるのは、地雷になりそうで怖いなと思います。

nowokaynowokay 2013/09/17 13:27 OffsetDateTimeがパフォーマンスいいという話、でもパフォーマンス気になるアプリは国際展開しててZoneDateTimeが必要、国内だけ前提のものはあまりパフォーマンス気にならないというのが大多数になりそうなので、ぼくはZoneDateTimeを主流として扱おうと思っています。
というか、入門書にOffsetDateTimeとの使い分けまで書けませんw

nowokaynowokay 2013/09/17 13:29 あ、日付しか操作しない場合(Periodは使ってもDurationを使わない場合)はOffsetDateTimeでいいのか。

cero-tcero-t 2013/09/17 14:40 フォーマッタがスレッドセーフになったのは良んですいが、「yyyy/MM/dd」だと「2013/9/17」はパースできず「2013/09/17」としないとダメ。
「2013/9/16」をパースしたければ「yyyy/M/d」にしなければならず、この辺りでおそらく死人が出ます。

nowokaynowokay 2013/09/17 16:27 見える!屍が見えるぞ!ってそれバグちがうのん?w

cero-tcero-t 2013/09/17 21:04 バグって言いたくなるぐらいフォーマッタが超厳密なんですよね。たとえばSimpleDateFormatが許していた「2013/09/17aaa」みたいなのをNGにしてくれる傍らで、「MMって言ってるんだから2桁に決まってるだろJK」みたいな勢いで1桁の月をエラーにしてくるんですよね。まぁ何にせよ死人が出ますw

skirnirskirnir 2013/09/18 10:57 「2013/9/16」をパースしたければ「yyyy/M/d」にしなければならず > yyyy/M/dd なんですかね。それにしても利用者のことを全く考えていない仕様ですね…。サポートメソッド用意するか…。

xuweixuwei 2013/09/18 14:23 ここのコメント欄引用してみんなでtweetしていたら、本人からmentionが来ました。という、報告・・・ https://twitter.com/jodastephen/status/379913392883892225 まだfeedbackして変わる段階なのだろうか・・・?

nowokaynowokay 2013/09/18 14:48 わー