新元号(令和)になって1ヶ月が経ち、Javaの各バージョンでも正式サポートが進んでいます。新元号の対応状況については、 @yamadamn さんの「Javaバージョン別の改元(新元号)対応まとめ」に非常に詳しくまとめられているので、そちらをご覧ください。
で・・・私が現在携わっている開発案件でも、(残念ながら?w)和暦を扱う必要があり、令和対応の検証を進めていた(実際は私の同僚が検証していた)のですが・・・、和暦文字列(例:R010501
,H310430
,S510101
,T100830
とか)を日付型(例:LocalDate
など)へ変換すると・・・特定の範囲(各元号の1〜11年)で+100年されてしまい同僚が頭を抱えていました(実際には頭を抱えてはいなかったですけどw)。
直感的にはバグ?と思ったのですが、ソースを追いかけてみたら、どうやらバグではなく仕様らしいということがわかりました。ただ・・・直感的には、仕様とは思えませんでしたけどw
検証バージョン
今回私が検証したのは(諸事情により)Java 8(AdoptOpenJDK 8u222 Nightly build for macOS x64 -7 June 2019版-)だけになりますが、おそらく全てのバージョンで同じ動作になると思われます。
問題となるパターン指定
具体的には、冒頭で紹介した形式の和暦文字列を日付型に変換する必要があったため、「GGGGGyyMMdd
」(暦年の桁を2文字固定にするためにyy
を指定)というパターン文字列を指定して、DateTimeFormatter
を生成していました。
DateTimeFormatter df = DateTimeFormatter
.ofPattern("GGGGGyyMMdd", Locale.JAPAN)
.withChronology(JapaneseChronology.INSTANCE)
.withResolverStyle(ResolverStyle.LENIENT);
多くのエンジニアの方は直感的には、問題が出るとは思わないのではないでしょうか?
実際に以下のように各元号の開始日・終了日を表す和暦文字列を日付型に解析してみると・・・
System.out.println("M---");
System.out.println(df.parse("M060101", LocalDate::from)); // 明治6年より前はサポートされていないため実際の開始日ではない
System.out.println(df.parse("M450729", LocalDate::from));
System.out.println("T---");
System.out.println(df.parse("T010730", LocalDate::from));
System.out.println(df.parse("T151224", LocalDate::from));
System.out.println("S---");
System.out.println(df.parse("S011225", LocalDate::from));
System.out.println(df.parse("S640107", LocalDate::from));
System.out.println("H---");
System.out.println(df.parse("H010108", LocalDate::from));
System.out.println(df.parse("H310430", LocalDate::from));
System.out.println(df.parse("H310501", LocalDate::from)); // H表記で令和元日を表す和暦文字列
System.out.println("R---");
System.out.println(df.parse("R010501", LocalDate::from));
System.out.println(df.parse("R000501", LocalDate::from));
M---
1973-01-01
1912-07-29
T---
2012-07-30
1926-12-24
S---
2026-12-25
1989-01-07
H---
2089-01-08
2019-04-30
2019-05-01
R---
2119-05-01
2118-05-01
となり、和暦は未来に進むのではなく、過去へ遡る暦であることがわかります
・・・もちろん和暦も未来へ進む暦ですので、バグ or パターン指定がまずい?ことが予想されます。
バグ vs 仕様?
感覚的にはバグだと思ってしまいましたが、ソース+JavaDocを確認したところ・・・どうやらこの動作は仕様のようです。
まず、y
を処理しているソースをみてみたところ・・・
// ...
case 'y':
if (count == 2) { // `yy`には特別に意味がありそう・・・ということがわかる
appendValueReduced(field, 2, 2, ReducedPrinterParser.BASE_DATE);
} else if (count < 4) {
appendValue(field, count, 19, SignStyle.NORMAL);
} else {
appendValue(field, count, 19, SignStyle.EXCEEDS_PAD);
}
break;
// ...
となっており、JavaDocでy
に対する仕様を確認してみると・・・・
Pattern Count Equivalent builder methods
------- ----- --------------------------
y 1 appendValue(ChronoField.YEAR_OF_ERA, 1, 19, SignStyle.NORMAL);
yy 2 appendValueReduced(ChronoField.YEAR_OF_ERA, 2, 2000);
yyy 3 appendValue(ChronoField.YEAR_OF_ERA, 3, 19, SignStyle.NORMAL);
y..y 4..n appendValue(ChronoField.YEAR_OF_ERA, n, 19, SignStyle.EXCEEDS_PAD);
という記載がありました。今度は・・・ appendValueReduced
メソッドの JavaDoc を確認してみると・・・フォーマット文字列として「yy
」を指定すると「2000-01-01
」を基準日付として、暦内の基準年(開始年?)を算出して、その基準年から100年間を扱うことができるフォーマット定義が生成されるようになっているみたいです。
JavaDocに記載されているルールを和暦に適用すると・・・・基準年は「12」になります。「12」になる経緯は・・・・
- 「2000-01-01」は和暦だと「平成12年1月1日」
- 「平成12年1月1日」の元号内での年は12
ということのようです。
なので・・・・令和であれば、
- 12〜99 : 令和12年〜令和99年
- 00〜11 : 令和100年〜令和111年
となるため、前述の実行結果がバグではなく仕様であることがわかります。ただし・・・少なくても我々日本人にとっては和暦を扱う場合には直感的ではないですよね。おそらく・・・「2000-01-01」がデフォルトの基準日付になっているのは、西暦での「00-99」を「2000年〜2099年」と解釈するするためだと思われます。
NOTE:
上記は令和を例に説明しましたが、基本的には他の元号(明治、大正、昭和、平成)でも同様のルールが適用されます。ただし、それぞれ終了年が決まっているため、終了年以降を表す値を指定した時の解釈については、令和とは異なることになります。
適切なフォーマット指定は?
要件に応じて、以下の2つの定義方法を使い分けることになると思われます。
NOTE:
実際に問題になることは限りなく0に近いと思いますが、「
R000101
」を指定した時の動作が異なります。ざっくり言うと・・・前者は「0年」として扱いエラーになるのですが、後者は「100年」として解釈した日付に変換されます。現在の元号法の範囲では、令和が100年以上続くことは事実上ないと思われる+その頃Javaが使われている可能性はかなり低い!?と思うので気にする必要はないかな〜と。
パターン表記を yy → y にする
暦年部が2桁固定であることにこだわる必要がなければ・・・「GGGGGyMMdd
」とすれば、和暦文字列を正しく解釈することができます。
DateTimeFormatter df = DateTimeFormatter
.ofPattern("GGGGGyMMdd", Locale.JAPAN) // 'yy' -> 'y' に変更
.withChronology(JapaneseChronology.INSTANCE)
.withResolverStyle(ResolverStyle.LENIENT);
appendValueReduced
メソッドを併用して暦の開始年を1にする
暦年部を2桁固定に限定する必要がある場合は・・・ パターン文字列とappendValueReduced
メソッドを併用してフォーマット定義を組み立てることで、和暦文字列を正しく解釈することができます。
DateTimeFormatter df = new DateTimeFormatterBuilder() // Builderを使う
.appendPattern("GGGGG")
.appendValueReduced(ChronoField.YEAR_OF_ERA, 2, 2, 1) // 歴年の部分はappendValueReducedを使って基準値を1に指定
.appendPattern("MMdd")
.toFormatter(Locale.JAPAN)
.withChronology(JapaneseChronology.INSTANCE)
.withResolverStyle(ResolverStyle.LENIENT);
formatとの相互運用性の考慮
parse
時だけのことを考えれば、ほとんどの場合はパターンとしてy
を指定すれば良いと思いますが、format
時との相互運用が必要+format
時も2文字固定(yy
)で出力にする必要がある場合は、「appendValueReduced
メソッドを併用して暦の開始年を1にする」方法を採用して、format
とparse
時に同じフォーマット定義を利用することを検討すると良いと思います。
まとめ
和暦文字列の解析用のパターン指定で「yy
」は使えないよ!
補足
SimpleDateFormat
に指定するパターンに「yy
」を指定した場合は、直感的に期待する動作になります。SimpleDateFormat
との互換性を捨ててまで仕様を変えた理由は私には思いつきませんでした(きっと深い理由があるのでしょうけど)。
参考サイト(関連サイト)
- https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatterBuilder.html#appendPattern-java.lang.String-
- https://docs.oracle.com/javase/jp/8/docs/api/java/time/format/DateTimeFormatterBuilder.html#appendValueReduced-java.time.temporal.TemporalField-int-int-java.time.chrono.ChronoLocalDate-
- https://qiita.com/yamadamn/items/56e7370bae2ceaec55d5