~saiya/hatenablog

No Code, No Life.

SimpleDateFormat の罠まとめ & 対策コード例

公式ドキュメント:SimpleDateFormat (Java Platform SE 8)

便利かつ頻繁に利用される SimpleDateFormat クラスだが、実際のところ罠が多い。

知らずに罠を踏んでいる事例を身の回りで何度も目にした上に、罠を網羅的にまとめた記事が少ないことに気がついたので、書いてみた。

まとめ

  1. マルチスレッドで使うと壊れる : 下手なことはせずインスタンスは毎回作るべし
  2. 実在しない日時が通る : setLenient(false) すべし
  3. 文字の不足や過剰があっても通る : format 結果と突き合わせるべし

全対策を盛り込んだコード

License: 以下 DateTimeUtil クラスのライセンスは Public Domain とする

import java.util.Date;
import java.text.ParseException;
import java.text.SimpleDateFormat;

class DateTimeUtil {
    /** (Thread safe) {@link SimpleDateFormat} を用いて日時文字列をパースする。<br />
    * SimpleDateFormat とは異なり、実在しない日時(ex. "2014/02/29")や
    * 桁数の不一致(ex. "yyyy/MM/dd" で "14/02/28")は許容しないし、先頭一致ではなく全体一致である。<br />
    * 参考: http://saiya-moebius.hatenablog.com/entry/2014/08/17/165322
    * 
    * @param format {@link SimpleDateFormat} 形式のフォーマット文字列
    * @param source パース対象の文字列
    * @return パース結果の日時
    * @throws ParseException    与えられた文字列をパースできなかった。
    */
    public static Date parseExact(String format, String source) throws ParseException {
        SimpleDateFormat sdf = new SimpleDateFormat(format);
        sdf.setLenient(false);
        Date result = sdf.parse(source);
        if(sdf.format(result).equalsIgnoreCase(source)) return result;
        throw new ParseException("Unparseable date: \"" + source + "\"", 0);
    }
}

マルチスレッドで使うと壊れる

Advice: 下手なことはせずインスタンスは毎回作るべし

日付フォーマットは同期化されません。スレッドごとに別のフォーマット・インスタンスを作成することをお薦めします。 SimpleDateFormat (Java Platform SE 8)

この公式ドキュメントの記載は脅しでもなんでもなく、SimpleDateFormat はパース中の内部状態をフィールド変数に持っている。

そのため、マルチスレッド動作するプログラムでインスタンスを使いまわすと高確率で死ぬ。

つい static final フィールドで持ちたくなる誘惑に駆られるが、インスタンスを毎回 new する習慣をつけたほうが良い。

なお、近年の JVMJIT, GC の最適化*1は賢いため、下手に synchronized で同期化したり ThreadLocal で使いまわすのはクヌース先生のいうところの「早すぎる最適化」になりがちであるし逆効果であることも多い*2。そういうことは本当に必要になってから考えよう。

実在しない日時が通る

Advice : setLenient(false) すべし

非厳密性 Calendarは、カレンダ・フィールドを解釈する際、厳密および非厳密の2つのモードを使用します。非厳密モードの場合、Calendarはそれ自身が生成する値よりも広範なカレンダ・フィールド値を受け入れます。Calendarが、get()の値を返すためにカレンダ・フィールド値を再計算する際、すべてのカレンダ・フィールドが正規化されます。たとえば、非厳密なGregorianCalendarは、MONTH == JANUARY、DAY_OF_MONTH == 32を2月1日として解釈します。 Calendar (Java Platform SE 8)

SImpleDateFormat はデフォルトでは setLenient(true) な状態(非厳密)であり、以下の JUnit 例が示す通り実在しない日付は自動的に繰り上げ・繰り下げが行われる。

@Test()
public void testLenient_OutOfRange() throws ParseException {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
    Assert.assertEquals("2014/03/01", sdf.format(sdf.parse("2014/02/29")));
}

多くの場合これは意図している挙動ではないので、SimpleDateFormat を使う場合は setLenient(false) にして使う習慣にするほうが良い。

@Test(expected = ParseException.class)
public void testNonLenient_OutOfRange() throws ParseException {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
    sdf.setLenient(false);
    sdf.parse("2014/02/29"); // 29 日は存在しない, ParseException
}

桁数不足や末尾が余計でも通る

Advice : ParsePosition を見るか format 結果と突き合わせるべし

Notice : これらの挙動は setLenient(false) しても止められない

この件については公式の記述を読むより下記コードを見た方がわかりやすいだろう:

@Test
public void testNonLenient_ExOrMissingChars() throws ParseException {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
    sdf.setLenient(false);
    Assert.assertEquals("2014/03/01", sdf.format(sdf.parse("2014/03/01ほげ"))); // 余計な文字
    Assert.assertEquals("2014/03/12", sdf.format(sdf.parse("2014/03/012")));  // 余計な文字
    Assert.assertEquals("2014/03/01", sdf.format(sdf.parse("2014/3/1"))); // 桁数不一致
    Assert.assertEquals("0014/03/01", sdf.format(sdf.parse("14/3/1")));   // 桁数不一致
}

setLenient(false) しているにもかかわらず、"yyyy/MM/dd" を書く側としては期待していない文字列が通ってしまっている。

特に後者の "14/3/1" が西暦 14 年になってしまうのは痛い。

公式ドキュメントでも実は説明されている

public Date parse(String source) throws ParseException 指定された文字列の先頭からテキストを解析して日付を生成します。メソッドは指定された文字列のテキスト全体に使用されない場合もあります。 DateFormat (Java Platform SE 8)

公式ドキュメントの言い回しはいまいち非直感的だが、要するに「末尾に余計な文字が付いていても無視するぞ」という意味である。

数値: フォーマット時に、パターン文字の数は最小桁数です。これより短い数値は、この桁数までゼロ埋めされます。解析には、2つの隣接するフィールドを区切る必要がないかぎり、パターン文字の数は無視されます。 SimpleDateFormat (Java Platform SE 8)

この公式ドキュメントも分かりづらいが、例えば "2014/03/01" であれば "/" で区切りが明確なので、"2014/3/012" のように桁数が余計でも "2014/3/1" のように足りない部分があっても許容されますよ、という意味である。

ParsePosition なるものもあるが...

このような問題への対策として ParsePosition なるものを利用することを対策としている記述もネット上に存在する。

ParsePosition オブジェクトを parse メソッドに渡すことで、parse 対象文字列の何文字目までがパースされたかを知ることができるので、パースされた文字数が文字列の長さと一致するのかを見る、という対策である。

しかしながらも、上記の通り日付の各部分の文字数が「足りなくても過剰でも」通ってしまう性質上、parse された文字数のチェックだけでは不十分である (例えば年が短くて日が長すぎるとか)。

また、ParsePosition オブジェクトを渡す場合 ParseException が発生しなくなってしまい、代わりに ParsePosition#getErrorIndex() を毎回調べなければいけない。これは、多くの場合エラーのチェック漏れバグの原因となる。

そのため、ParsePosition を用いるよりも、素直に parse で得られた Date を format して元の文字列と一致するかをチェックした方が早い。

*1:Escape Analysis によってインスタンスをスタックに割り当てる、スレッド固有のヒープに割り当てる、など

*2:かえって同期化のコストが大きくなったり ThreadLocal のクリーンナップが問題になったりすることも多い