13
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Javaにおける日付計算の実装方法

Posted at

Javaにおける日付計算の実装は知らないとハマりがちな落とし穴が多く、自分の身の回りでもそれによる障害等も発生しているので整理してみます。
例えば、翌月末日日付を取得したい場合を考えてみます。

Java8の場合

標準APIである、Date and Time APIを使うのがよいです。

LocalDate nextMonth = LocalDate.now().plusMonths(1L);
LocalDate lastDayOfNextMonth = nextMonth.withDayOfMonth(nextMonth.lengthOfMonth());

例えば、これを2016/8/31に実行した場合、1行目のコードで .plusMonths(1L) により月が1加算されますが、9/31は存在しないため9月で最後に有効な日である9/30に調整されます。(2行目でも9月の最終日である30日がセットされ、lastDayOfNextMonthは2016/9/30になります)
cf.) https://docs.oracle.com/javase/jp/8/docs/api/java/time/LocalDate.html#plusMonths-long-

Java7以下の場合

標準APIでは、java.util.Calendarを使って実装できます。

Calendar calendar = new GregorianCalendar();
calendar.add(Calendar.MONTH, 1);
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
Date lastDayOfNextMonth = calendar.getTime();

これで翌月末日日付が取得できます。例えば、これを2016/8/31に実行した場合、月を1加算すると9/31となり、存在しない日付のため9月で最後に有効な日である9/30に調整されます。
ここまではDate and Time APIとあまり使い勝手は変わらないようにも見えますが、APIドキュメントの説明を見比べてみます。(見やすいように適宜改行を追加しています)

  • Date and Time APIのLocalDate#plusMonths
    https://docs.oracle.com/javase/jp/8/docs/api/java/time/LocalDate.html#plusMonths-long- より

     指定された月数を加算した、このLocalDateのコピーを返します。
     
     このメソッドは、3つの手順で、指定された量を月フィールドに加算します。
     
         入力された月数を、月フィールドに加算します
         結果となる日付が無効になるかどうかをチェックします
         必要に応じて、「月の日」を最後の有効な日に調整します
     
     たとえば、2007-03-31に1月を加算すると、2007-04-31という無効な日付が生じます。無効な結果を返す代わりに、
     その月の最後の有効な日である2007-04-30が選択されます。
     
     このインスタンスは不変で、このメソッド呼び出しによって影響を受けません。
    
  • GregorianCalendar#add
    http://docs.oracle.com/javase/jp/7/api/java/util/GregorianCalendar.html#add(int,%20int) より

     カレンダの規則に基づいて、指定された (符号付きの) 時間量を、指定されたカレンダフィールドに加えます。
     
     Add 規則 1。呼び出しが field で発生したモジュロオーバーフロー amount になる前に、呼び出しで field の値を引いたあとの field の値です。
     オーバーフローは、フィールドの値が範囲を超え、その結果、次の大きいフィールドが増分または減分されて、
     フィールドの値がその範囲に入るよう調整された場合に発生します。
     
     Add規則 2。小さいフィールドが不変式であると予想される場合に、field が変更されてから最小値または最大値が変更されたために、
     その前の値と等しくならないと、フィールドの値はその予想される値にできるだけ近くなるように調整されます。小さいフィールドは、
     小さい時間の単位を表します。HOUR は DAY_OF_MONTH よりも小さいフィールドです。不変式ではないと予想される小さいフィールドは、調整されません。
     カレンダシステムでは、不変式であると予想されるフィールドが判断されます。
    

LocalDate#plusMonthsの挙動が明確に記載されているのに対し、GregorianCalendar#addはちょっと何を言っているのかよくわかりませんね。。。Add規則1、2については、http://docs.oracle.com/javase/jp/7/api/java/util/Calendar.html の最初の方にも説明があり、そこには以下のようにもう少しわかりやすく例が記載されていたりしますが、それでもわかりにくいと思います。

例:最初に 1999 年 8 月 31 日に設定された GregorianCalendar を考えます。add(Calendar.MONTH, 13) を呼び出すと、
カレンダが 2000 年 9 月 30 日に設定されます。8 月に 13 か月を追加すると翌年の 9 月になるため、Add 規則 1 によって 
MONTH フィールドが 9 月に設定されます。DAY_OF_MONTH は GregorianCalendar では 9 月の 31 日にはできないため、Add 規則 2 によって
 DAY_OF_MONTH がもっとも近い可能な値の 30 に設定されます。これは小さいフィールドですが、GregorianCalendar で月が変更されるときに
変更が予定されているため、DAY_OF_WEEK はルール 2 によっては調整されません。

また、翌月の月は前の処理で算出済みで末日だけ取得したいみたいな場合にCalendar#setで翌月をセットしようとすると、以下のように思わぬ挙動に面食らうことになったりします。

Calendar calendar = new GregorianCalendar();
calendar.set(Calendar.MONTH, 翌月);
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
Date lastDayOfNextMonth = calendar.getTime();

上と異なるのは、2行目がaddではなくsetしている点のみです。
これを7/31に実行した場合は8/31と正しい結果になりますが、8/31に実行した場合、結果は10/1になってしまいます。setの場合はaddとは異なり、存在しない日付になると繰り上がりが発生するためです。
さらにここで注意が必要なのが、結果が10/31ではなく10/1になる点です。翌月をセットした時点で繰り上がりが発生していれば、10/31になるのが自然に思えます。翌月をセットした時点の値を確認するために、以下のようにsetの後にgetTimeして値を出力するコード(3行目)を追加してみますと、

Calendar calendar = new GregorianCalendar();
calendar.set(Calendar.MONTH, 翌月);
System.out.println(calendar.getTime());
calendar.set(Calendar.DAY_OF_MONTH, calendar.getActualMaximum(Calendar.DAY_OF_MONTH));
Date lastDayOfNextMonth = calendar.getTime();

なんと、結果が10/31に変わります。
Calendarクラスは内部的に年月日等のフィールドをそれぞれ保持しており、getメソッドやaddメソッド、getTimeメソッド等を実行する際には各フィールド値が再計算されるのに対し、setメソッドはフィールドに値をセットするだけで再計算は行われず、あとでget等を実行した際にまとめて再計算されます。つまりset時点ではCalendarに設定されている日付時刻にはまだ反映されません。このため、setの途中でgetTimeを挟むことで結果が異なるという挙動になります。
これは直感的に期待される挙動とは異なるかと思いますので、挙動をよく把握していないとうっかりバグを作り込んでしまうことになりかねないと思います。
このsetメソッドの挙動については、APIドキュメント http://docs.oracle.com/javase/jp/7/api/java/util/Calendar.html の最初の方にも説明されています。(見やすいように適宜改行を追加しています)

set(f, value) では、カレンダフィールド f が value に変更されます。さらに、カレンダフィールド f が変更されたことを示すように
内部メンバー変数が設定されます。カレンダフィールド f はただちに変更されますが、カレンダの時間値 (ミリ秒) は、
get()、getTime()、getTimeInMillis()、add()、または roll() が次に呼び出されるまで再計算されません。
このように、set() を複数回呼び出しても、不要な計算が行われることはありません。set() を使用してカレンダフィールドを変更すると、
カレンダフィールド、カレンダフィールド値、およびカレンダシステムによってほかのフィールドも変更されることがあります。
さらに、get(f) では、カレンダフィールドの再計算後に、set メソッドを呼び出して設定された value が必ず返されるとは限りません。
これらの詳細は、具象カレンダクラスによって決定されます。

例:最初に 1999 年 8 月 31 日に設定された GregorianCalendar を考えます。set(Calendar.MONTH, Calendar.SEPTEMBER) を呼び出すと、
日付が 1999 年 9 月 31 日に設定されます。これは一時的な内部表現であり、getTime() を呼び出すと 1999 年 10 月 1 日になります。
ただし、getTime() を呼び出す前に set(Calendar.DAY_OF_MONTH, 30) を呼び出すと、set() 自体のあとに再計算が行われるために、
日付が 1999 年 9 月 30 日に設定されます。

なお、Java8のDate and Time APIの場合は、

LocalDate nextMonth = LocalDate.now().withMonth(翌月);
LocalDate lastDayOfNextMonth = nextMonth.withDayOfMonth(nextMonth.lengthOfMonth());

のように翌月を指定するコードを8/31に実行した場合でもaddの場合と同様に、結果は9/30となります。
APIドキュメント https://docs.oracle.com/javase/jp/8/docs/api/java/time/LocalDate.html#withMonth-int- にもちゃんと以下のように記載があり、安心です。

その「月の日」がその年に対して無効である場合は、その月の最後の有効な日に変更されます。 

他にも、Calendarには直感的ではなく、初心者がやりがちなミスとして、9月をセットするつもりで、

calendar.set(Calendar.MONTH, 9);

と書いてしまうというのもあります。実際にはこれは10月をセットしたことになってしまいます。
Calendarの月は1-12ではなく、0-11ですので、9月を指定する場合は

calendar.set(Calendar.MONTH, 8);

と書かないといけません。直感的に読みにくくわかりにくいですので、

calendar.set(Calendar.MONTH, Calendar.SEPTEMBER);

のように定数を使って書くのがいいでしょう。

どうするのがいいか?

上記のようなCalendarクラスの挙動をしっかり把握できている場合はCalendarを使うのもいいと思いますが、ややこしいですよね。
Java7以前までJavaにおける日付ライブラリの定番的な存在だったJoda-Time http://www.joda.org/joda-time/ を使うと以下のように素直に書けます。

Date lastDayOfNextMonth = LocalDate.now().plusMonths(1).dayOfMonth().withMaximumValue().toDate();

翌月の月を指定する場合は以下のようになります。

Date lastDayOfNextMonth = LocalDate.now().withMonthOfYear(翌月).dayOfMonth().withMaximumValue().toDate();

なお、既存でJoda-Timeを使っておらず、将来的にJava8への移行も考えている場合であれば、Joda-Timeの開発者にして、Date and Time API(JSR-310)のスペックリードであるStephen Colebourne氏が、Java8 Date and Time APIのクラスをJava6/7向けにバックポートしたライブラリとしてThreeTen-Backport http://www.threeten.org/threetenbp/ を出していますので、そちらを使うのもいいと思います。

13
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?