[Swift] DateFormatter と yyyy と YYYY

     

TAGS:

⌛️ 3 min.
DateFormatter の使い方と 間違いやすい yyyy と YYYY について説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.4 beta2
  • Xcode 13.3.1
  • iOS 15.4

DateFormatter

Date という日付を表すデータ型があります。この Date 型を文字列に変換するときに、人間が読みやすい形式に変換してくれる DateFormatter というものが用意されています。

例えば、Date を “2022/03/04 16:30” というようなフォーマット沿った文字列に変換することができます。

DateFormatter の使用例

フォーマットの詳細を指定しなくても、簡単に表記を指定する方法が用意されています。

DateFormatter の .dateStyle というプロパティを使用することで 日付のフォーマットを指定できます。
.timeStyle というプロパティを使用すると 時刻のフォーマットを指定できます。


let date = Calendar.current.date(from: DateComponents(year: 2020, month: 03, day: 04, hour: 16, minute: 30))!

let fmtShort: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateStyle = .short
    fmt.timeStyle = .short
    return fmt
}()
let fmtMedium: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateStyle = .medium
    fmt.timeStyle = .medium
    return fmt
}()
let fmtLong: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateStyle = .long
    fmt.timeStyle = .long
    return fmt
}()

let fmtFull: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateStyle = .full
    fmt.timeStyle = .full
    return fmt
}()

print(fmtShort.string(from: date))
print(fmtMedium.string(from: date))
print(fmtLong.string(from: date))
print(fmtFull.string(from: date))
// print-out
3/4/20, 4:30 PM
Mar 4, 2020 at 4:30:00 PM
March 4, 2020 at 4:30:00 PM GMT+9
Wednesday, March 4, 2020 at 4:30:00 PM Japan Standard Time

フォーマット指定

DateFormatter を使うときに、具体的なフォーマットを指定することもできます。

例えば、2020年3月4日 16:30 を “2020/03/04 16:30” という文字列にするためには、以下のようになります。


import Foundation

let fmt: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateFormat = "yyyy/MM/dd HH:mm"
    return fmt
}()

let date = Calendar.current.date(from: DateComponents(year: 2020, month: 03, day: 04, hour: 16, minute: 30))!

print(fmt.string(from: date))
// print
2020/03/04 16:30

カレンダー指定

DateFormatter には、Calendar を指定することができるようになっています。
DateFormatter で使用する表記の一部は、使用するカレンダーによって異なるためです。

無指定時には、Calendar.current が使用されます。

MEMO

カレンダー指定は、普段意識しないかもしれませんが、フォーマット指定との組み合わせで不整合が発生することがあり、問題をわかりにくくすることがあります。

フォーマット指定文字のワナ

このフォーマット指定の文字で ハマることがあります。

1つの要素を表示するときに、複数の表示方法があるためです。

Apple のドキュメントは、こちら
# なぜか、古い形式のドキュメントしか見つかりませんでした・・・

より詳細は、Unicode のドキュメントを参照することになります。

時間指定 hh と HH

時間については、2種類の表示が可能です。12時間表記と24時間表記です。
例えば、午後4時を 4:00 と書くか、16:00 と書くか ということです。

フォーマット指定では、12時間表記を hh として、24時間表記を HH として指定することができます。


import Foundation

let fmt12h: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateFormat = "yyyy/MM/dd hh:mm"
    return fmt
}()
let fmt24h: DateFormatter = {
    var fmt = DateFormatter()
    fmt.dateFormat = "yyyy/MM/dd HH:mm"
    return fmt
}()

let date = Calendar.current.date(from: DateComponents(year: 2020, month: 03, day: 04, hour: 16, minute: 30))!

print(fmt12h.string(from: date))
print(fmt24h.string(from: date))

// print-out
2020/03/04 04:30
2020/03/04 16:30

実際に、午後の時間を対象に確認しないと 違いが発生しないので、動作確認に必要なパターンを増やす必要があります。

カレンダーの違いによる差異

上記の12時間/24時間表記は、日常生活で目にしやすいものなので、理解しやすいですし、特に違和感はありません。

しかし、以降で説明するカレンダーの違いによる差異は、通常1つのカレンダーしか使わない ために、日常生活であまり目にしません。ですので、ピンと来ないかもしれませんが、日付の表示に関わるので、不具合になると厄介です。

グレゴリオ暦 と ISO-8601

まず、代表的なカレンダーを説明します。

・グレゴリオ暦
・ISO 8601

上記以外にも macOS/iOS 等で扱えるカレンダーはありますが、ここでは説明しません。

MEMO

よく JSON で日付表記を扱うときに出てくる ISO も ISO 8601 のことです。

グレゴリオ暦

日本で広く使われているのは、グレゴリオ暦です。
Wikipedia は、こちら

いわゆるカレンダーです。壁にかかっているものを見てもらうとそれが全てです。

特徴は以下です。
・週は日曜開始
・1年は、52週 もしくは 53週で構成される
・1年は、1月1日を持つ週を 第1週とする

ISO 8601

ISO 8601 の特徴は、以下の通りです。

・週は月曜開始
・週を単位とするカレンダー
・4半期は、(基本的に)13週
・1年は、52週 もしくは 53週で構成される
・1年は、最初の木曜日を持つ週を 第1週とする

MEMO

ISO-8601 のオリジナルを読むには、ISO からドキュメントを購入する必要があります。
私は、購入していないので、一部誤りがあるかもしれません。指摘いただけると助かります。

週番号 ww

ww は、(笑)ではなく、週番号を表示するために、使用するものです。

週番号は、年の初めから週に番号を振っていったものです。
CW01 というように、週を指定したり、CW01d5 のように、週の中の特定の日付を指したりします。

気を付けるべき点は、グレゴリオ暦とISO 8601 では、週番号の振り方が異なることです。

グレゴリオ暦は、1月1日の週が 第1週ですが、ISO 8601 では、最初の木曜日を持つ週が 第1週です。
結果として同じ週番号を持つ年もありますが、異なる年もあります。
つまり、この週番号は、使用するカレンダーによって異なる値を持つということになります。

しかも年によっては同じ値を持つのでやっかいです。

以下は、2020年1月1日と2022年1月1日の週番号を表示するコードです。


let fmtGre: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .gregorian)
    fmt.dateFormat = "ww"
    return fmt
}()
let fmtISO: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .iso8601)
    fmt.dateFormat = "ww"
    return fmt
}()

let date20200101 = Calendar.current.date(from: DateComponents(year: 2020, month: 1, day: 1, hour: 12))!
let date20220101 = Calendar.current.date(from: DateComponents(year: 2022, month: 1, day: 1, hour: 12))!

print(fmtGre.string(from: date20200101))
print(fmtISO.string(from: date20200101))

print(fmtGre.string(from: date20220101))
print(fmtISO.string(from: date20220101))
// print-out
01
01
01
52

2020年1月1日は、グレゴリオ暦と ISO 8601 のいずれでも、第1週となっていることが確認できますが、
2022年1月1日は、グレゴリオ暦では、第1週、ISO 8601 では、第52週となっていることがわかります。

このようになる理由を確認してみます。実際には2022年1月1日は、土曜日です。

2022Jan

ISO 8601 としては、2022年の最初の木曜日は、1月6日であり、その週が 第1週です。
第1週に、1月1日は含まれていません。そのようなケースでどうなるかというと、2022年1月1日は、2021年の第52週に含まれているということになります。

このように DateFormatter に設定される カレンダーによって、同じ Date を与えても 異なる値を出力することがあります。

年指定 yyyy と YYYY

年の表示にも 複数の表示があります。

yyyy と YYYY という2種類の表示方法があります。

複数の表示と聞いて、たいていの人は、? が頭に浮かぶはずです。 2020年は、2020年だよね? 他にある? という感じです。

MEMO

細かい点ですが、年を表示桁数を制御するために、(4文字でない)yy や YY を使うこともでき、違いは、以降で説明する yyyy と YYYY の差異と同じです。

ですので、yyyy と YYYY について、わからないのも普通です。

yyyy と YYYY の差異

説明が長くなるので、最初に差異を説明してしまいます。

yyyy は、その日付を含む年を意味します。

YYYY は、ISO-8601 が定義する “Week of year” ベースのカレンダーでの年 です。

詳細説明は以降でおこないますが、ざっくりとした YYYY の説明は以下の通りです。
「日付の所属する週が所属する年を表す」

どのような値になるかでいうと、以下のようになります。
「多くの場合は、YYYY は、yyyy と同じ数字になるが、年によっては 1月1日前後数日の YYYY の値は、翌年/前年の数値となる」

MEMO

毎年のことではなく特定の年の年末年始の数日が異なる数値を持つため、気をつけて検証しないと見逃されてしまい、不具合の温床になることがあります。

例えば、2022/1/1 の持つ “week of year” ベースの年は、2021 です。
先ほど確認したときに、第52週とわかりましたが、”2021年の”第52週であることは、この YYYY を使うことで確認できます。


let fmtGre: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .gregorian)
    fmt.dateFormat = "yyyy ww"
    return fmt
}()


let fmtISO: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .iso8601)
    fmt.dateFormat = "YYYY ww"
    return fmt
}()

//let date20220101 = Calendar.current.date(from: DateComponents(year: 2022, month: 1, day: 1, hour: 12))!

print(fmtGre.string(from: date20220101))
print(fmtISO.string(from: date20220101))
// print-out
2022 01 // 2022年第1週
2021 52 // 2021年第52週

最初は、バグかと思えますが、バグではありません。(製作者側の)意図した動作です。

先ほどの週番号の延長で考えると、ルールに沿って計算された結果ということがわかります。

“その日付の属する週”が属する年 です。

DateFormatter と カレンダー

YYYY を使うときには、使用するカレンダーを正しく DateFormatter に指定しないといけません。

yyyy の代わりに、YYYY を使って 意図しない動作になったときに、問題をより複雑にするのが、適正でないカレンダーを使用していることに由来するものです。

YYYY は、ISO 8601 をベースに規定されていますので、YYYY を指定するときには、カレンダーは ISO 8601 であることが想定されています。

ですが、DateFormatter は、無指定時には、current を使用しますので、そのカレンダーが グレゴリオ暦であると、YYYY の値がより(?) 意味不明なものになります。

2021年12月26日を例として確認してみます。
fmtYYYGre が グレゴリオ暦を使用して YYYY を使うフォーマッターです


let fmtGre: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .gregorian)
    fmt.dateFormat = "yyyy"
    return fmt
}()

let fmtYYYYGre: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .gregorian)
    fmt.dateFormat = "YYYY"
    return fmt
}()


let fmtISO: DateFormatter = {
    var fmt = DateFormatter()
    fmt.calendar = Calendar(identifier: .iso8601)
    fmt.dateFormat = "YYYY"
    return fmt
}()

let date20211226 = Calendar.current.date(from: DateComponents(year: 2021, month: 12, day: 26, hour: 12))!

print(fmtGre.string(from: date20211226))
print(fmtYYYYGre.string(from: date20211226))
print(fmtISO.string(from: date20211226))
// print-out
2021
2022
2021

なんと、2021年12月26日が、2022 年にあると計算されています。

2021Dec

確実に言えることは、YYYY は、ISO で定義されているものなのに、グレゴリオ暦が 計算に使用されている ということです。設定に不整合があるので、結果がおかしくなるのは自然なことです。

まとめ

DateFormatter の使い方から始めて、yyyy と YYYY の違いについて DateFormatter とカレンダーの関係や グレゴリオ暦と ISO 8601 の相違点を使って説明しました。

yyyy と YYYY の違い
  • DateFormatter を使うと簡単に Date を指定フォーマットの文字列に変換できる
  • DateFormatter は、指定されたカレンダーに従ってフォーマットする
  • グレゴリオ暦と ISO 8601 では週番号は異なる時がある
  • 週番号が異なるときに、yyyy と YYYY は異なる値を持つことがある

説明は以上です。
不明な点やおかしな点ありましたら、こちら もしくはコメントでお願いします。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です