概要
java.util.Calendar を使っていた日付処理のコードを、Java SE 8 で追加された Date and Time API を使って書き直してみたところ、そこそこ使い方がわかってきたので、記事にまとめてみました。
背景
Java で日付操作をするクラスには Calendar と Date があります。Java SE 7以前から Java を使っている場合は、このクラスの使い方を習得して使いこなしている方が多いのではないかと思います。
さて、Java SE 8 では Date and Time という新しい日付操作用のライブラリが追加されました。これまでの Calendar や Date との互換がない、思い切った新設計のライブラリです。そのため、「いまある Calendar や Date で問題なくプログラミングできているのに、わざわざ得体のしれないものを使う意味がわからない」と敬遠している方がいらっしゃるのではないかと思います。私もそうでした。
最近になって「ほんとうに便利だった業務で使えるJava SE8新機能(JJUG CCC 2015 Spring)」を拝読し、Date and Time の便利さを知りました。あと半年したら Java SE 9 も出ますし、このタイミングを逃すと一生 Calendar クラスを使い続けるプログラマーになりそうな気がしたので、思い切って Calendar と Date を自分のコードから全廃するリファクタリングをやってみました。
ちゃんと勉強するなら
Date and Time API の詳細については Qiita の「Java8の日時APIはとりあえずこれだけ覚えとけ」が非常に優秀です。有料の情報なら櫻庭さんの本(『現場で使える[最新]Java SE 7/8 速攻入門』)を読むとよいです。後者は Date and Time API の背景からしっかりと学ぶことができますし、Java SE 7の新要素や Java SE 8 のラムダ式&Stream API についても詳しく学べます。お勧めです。
Date and Time API とは
Java SE 8 から追加された新しい日付操作ライブラリです。櫻庭さんの著書によると、 Joda Time の作者である Stephen Colebourne 氏 (@jodastephen)が中心になって仕様策定されたそうです。
Calendar と比べて
あまりに巨大すぎて役割がてんこ盛りになっていた Calendar が、適切に分解された感があります。例えば、日付の比較しかしない場合、時刻の情報は一切使いませんが、そういう場合は LocalDate を使うことで処理を書くことができます。
マルチスレッド環境でも安全に動作
また、イミュータブルかつスレッドセーフである点も見逃せないポイントです。従来の日付 API はほぼスレッドセーフでなかったので、ThreadLocal と組み合わせて使うか、 Joda Time を使うのが基本でした。
パフォーマンスは従来と同等
新しくなったからといって従来のAPIより性能が落ちていたら置き換える意味がありません。ですが、「Java SE 8のDate and Time API、ラムダ式、Stream APIは本当に使えるか? 従来コードとのパフォーマンス面の違いを検証する」によると、ほぼ遜色ないパフォーマンスが出るようです。その点を心配する必要はありません。
従来の API との対応
用途 | 7まで | 8以降 |
---|---|---|
日付から時刻まで使う場合 | Calendar | LocalDateTime / OffsetDateTime / ZonedDateTime |
日付しか使わない場合 | Calendar | LocalDate |
時刻しか使わない場合 | Calendar | LocalTime / OffsetTime |
時差を考慮する場合は ZonedDateTime や OffsetDateTime を使う必要があります。今回は LocalDateTime と LocalDate を使いました。
Calendar の良くないと思ったところ
- 何でもできすぎる……このクラスだけで日付も曜日も時刻も扱えてしまいます
- 昔ながらの設計を引きずっている……SE 5以前(Date は JDK 1.0、Calendar は JDK 1.1)からあるので、 enum で定義すべき値を int 定数で定義 しています。数値自体に意味のない int 定数はバグを作り込みやすいのでなるべく避けたいところです。
- 突然の 0……理由はわかりませんが、このクラスは月を内部的に0から11の範囲で扱っています。2月の Calendar オブジェクトを取得したい場合は month に 1 を指定しなければいけません。知っていれば何のことはありませんが、知らなければ直感的でなく面倒なだけの仕様で、実際に Java の開発経験の浅い後輩がこの罠にはまっていました。
実行環境
Java SE | 1.8.0_102 |
---|---|
OS | Windows 10 |
Eclipse | 4.5 |
値を指定してオブジェクトを取得
GregorianCalendar クラスを使っていたようなコードは下記のように書けます。これで「2月を指定したはずなのに3月になっている」とかいう「Java 日付あるある」からおさらばできます。
東京の3月30日12時20分59秒を取り出したい
Calendar
「何で2月なのに30日なんて取り出そうとしているんだ」と戸惑うこと請け合いです。
new GregorianCalendar(2016, 2, 30, 12, 20, 59, 0);
もちろん2月ではなく3月のオブジェクトを生成しています。
Wed Mar 30 12:20:59 JST 2016
Date and Time API
ちゃんと month に 3 を指定して 3月のオブジェクトを取得できます。
ZonedDateTime.of(2016, 3, 30, 12, 20, 59, 0, ZoneId.of("Asia/Tokyo"))
値の操作
1か月先に進めたい
Calendar
Calendar の add メソッドは、オブジェクトの状態を直接操作できるので、再代入は必要ありません。Month を操作するのも DAY_OF_MONTH を操作するのも、減算する場合も、同じ add メソッドを使います。どのフィールドを操作するのかを第1引数で指定するのですが、これが int 定数なので下手をするとバグの温床にもなります。
cal.add(Calendar.MONTH, 1);
Calendar.MONTH の値は 2 なので、下記のコートでも何の問題もなくコンパイルが通りますし、上記のコードと同じように動作してしまいます。
cal.add(2, 1);
MONTH と 2 の間には何の関係も存在しないので、上記のコードから「あ、これは cal オブジェクトから月を取り出しているんだな」と思えるプログラマーは相当の Java 通だと思います。まあ、実際の開発で上記のようなコードを書くことはありえないでしょうが……
Date and Time API
注意しないといけないのは、 Date and Time API のクラスのオブジェクトが Immutable であることです。 plusXXX や minusXXX を呼んでも、そのオブジェクト自体の状態は変化しないので、帰ってくる新しいオブジェクトを受け取る必要があります。
ldt = ldt.plusMonths(1);
メソッドの時点で単位を指定しているので、コードから処理と意図を読み取ることが容易です。
曜日の操作
Calendar
public final static int SUNDAY = 1;
public final static int MONDAY = 2;
public final static int TUESDAY = 3;
public final static int WEDNESDAY = 4;
public final static int THURSDAY = 5;
public final static int FRIDAY = 6;
public final static int SATURDAY = 7;
Date and Time API
Calendar の SATURDAY 等に当たるものに DayOfWeek という enum が追加されたので、それを使います。ただし、Calendar のとは値が対応していない(後述の表を参照)ので、そのまま置き換えただけだとバグが出ます。
実装
enum で定義されています。
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY;
数値は enum の順番に 1 を加算したものを返しているようです。前述の通り、Calendar の DayOfWeek 値とは一致しないので、置き換えの際は注意してください。
public int getValue() {
return ordinal() + 1;
}
DayOfWeek 値の対応表
Class | 1 | 7 |
---|---|---|
Calendar | 日 | 土 |
DayOfWeek | 月 | 日 |
月の扱い
月は Month という enum に入っています。もちろん SEPTEMBER の value は 9 です。
Calendar
public final static int FEBRUARY = 1;
public final static int MARCH = 2;
public final static int APRIL = 3;
public final static int MAY = 4;
public final static int JUNE = 5;
public final static int JULY = 6;
public final static int AUGUST = 7;
public final static int SEPTEMBER = 8;
public final static int OCTOBER = 9;
public final static int NOVEMBER = 10;
public final static int DECEMBER = 11;
public final static int UNDECIMBER = 12;
UNDECIMBER というのがわからなかったので調べてみたところ、太陰暦等で使う13番目の月として用意してあるらしいです。
Date and Time
JANUARY,
FEBRUARY,
MARCH,
APRIL,
MAY,
JUNE,
JULY,
AUGUST,
SEPTEMBER,
OCTOBER,
NOVEMBER,
DECEMBER;
enum の定義順に +1 して返しているようです。
public int getValue() {
return ordinal() + 1;
}
月の値の取得
Calendar
Calendar cal = new GregorianCalendar(2016, 1, 29);
final int month = cal.get(Calendar.MONTH));
もう書くまでもないかもしれませんが、 month の値は 1 です。「1をセットしたのだから当たり前だろう?」ええ、ですがこの cal オブジェクトは 2016年2月29日 を表現しています。
System.out.println(cal.getTime().toString());
Mon Feb 29 00:00:00 JST 2016
Date and Time API
final LocalDate ld = LocalDate.of(2016, 2, 29);
ld.getMonth();
ld.getMonthValue();
ld.getMonth() では Month の FEBRUARY が、ld.getMonthValue() では int の 2 が、それぞれ取得できます。もちろん、このオブジェクトも 2016年2月29日 を表現しています。
System.out.println(ld.toString());
2016-02-29
オブジェクトと文字列の相互変換
従来
スレッドセーフでなかった SimpleDateFormat がよく使われていました。Date の Formatter なので Calendar クラスのオブジェクトを直接変換することはできません。Calendar.getTime() メソッドを使う必要があります。
final SimpleDateFormat formatter = new SimpleDateFormat("yyyy年 M月 d日 H時 m分 s秒 S(E)");
formatter.format(new Date()).toString();
Date and Time API
DateTimeFormatter が用意されています。
final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy年 M月 d日 H時 m分 s秒 S(E)");
formatter.format(LocalDateTime.now()).toString();
これだけ見ると SimpleDateFormat より DateTimeFormatter の方がタイプ量多いのではと思われるかもしれませんが、 SimpleDateFormat を使うときは大抵 ThreadLocal を使うので、それが不要になるのがよいです。
閑話
ThreadLocal で SimpleDateFormat を扱うイディオムは、あまりに何度も書いていたので完全に覚えてしまいました。
private static final ThreadLocal<DateFormat> DATE_FORMAT = new ThreadLocal<DateFormat>() {
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyyMMdd HH:mm:ss.SS");
}
};
ちなみに、Java SE 8 からは withInitial というメソッドが追加されていまして、上記の長ったらしい記述をラムダ式により簡潔に書くことができます。
private static final ThreadLocal<DateFormat> DATE_FORMAT
= ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HH:mm:ss.SS"));
long 値との相互変換
Unix時間(1970-09-01からの経過時間)は、 Java だとミリ秒で扱うことが多いです。他のプログラミング言語での経験があると、ここが意外にはまりポイントになったりしています。DBから取得した Timestamp を Java でそのまま使おうとしたら桁が3つずれていて、意図した結果を得られなかった、ということもあったかもしれません。
long 値 -> オブジェクト
従来
new Date(ms);
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(ms);
Date and Time
ofInstant を使うのが早そうでした。
LocalDateTime
.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault())
LocalDate も LocalDateTime から変換してしまった方が早そうです。
LocalDateTime
.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault())
.toLocalDate();
オブジェクト -> long
従来
Date には getTime() メソッドがあります。
final long currentMs = new Date().getTime()
Calendar には getTimeInMillis というメソッドがありますので、それを使えばよいです。
final Calendar cal = Calendar.getInstance();
final long currentMs = cal.getTimeInMillis();
Date and Time API
Date and Time API のクラスにはそれらしいメソッドがありませんでしたので、下記のように変換しました。
final long currentMs = LocalDateTime.now().toEpochSecond(ZoneOffset.ofHours(9)) * 1000L)
指定した日付のオブジェクトを取得
従来
まず Calendar インスタンスを取得して、それに値をセットします。
Calendar cal = Calendar.getInstance();
cal.set(year, month - 1, day);
あるいは GregorianCalendar を使います。
new GregorianCalendar(2015, 0, 1)
Date and Time
直接オブジェクトを取得できます。Month の -1 とかもいりません。
LocalDate ld = LocalDate.of(year, month, day);
LocalDate から LocalDateTime への変換
Date and Time API
LocalDate.atStartOfDay() を使うと、その日の0時0分0秒で LocalDateTime オブジェクトに変換できます。
LocalDate ld = LocalDate.now();
LocalDateTime ldt = ld.atStartOfDay();
まとめ
当然ですが、従来の API でできることは Date and Time API でも実装可能でした。スレッドセーフになって、より扱いやすくなった標準日付 API を導入することで、より生産性の高いプログラミングができるようになるでしょう。
参考
書籍
Web
ほか
Java SE 8を使えない環境(Android アプリ等)では、まだまだ Joda Time が活躍するでしょう。