【Swift】Dateの王道 【日付】

## 概要
時間という概念は、概念と呼ばれるものの中で最も日常に溶け込んだ存在です。

本投稿では、まずはじめにSwiftでの正しい日付・時刻の扱い方を知るために、その仕組みとなっている時間・時刻の定義についてまとめていき、その後`Date`を扱うときのアンチパターンとベストプラクティスについて考え、その対応について紹介していきたいと思います。

## GMT / UTC / JST / UNIX TIMEについて
Swiftで`Date`を扱う前に時刻とはなにかについて知る必要があります。ここでは世界で主に使われる時刻の定義の種類について簡単におさらいをします。

### GMT
GMT(グリニッジ平均時): UTCが現れる以前まで世界共通時として扱われていたものであり、経度0からの平均太陽時を指します。現在ではUTCと同義で扱われることが多いですが、厳密には異なりGMTは`うるう秒`が考慮されないため`UTC`とは100年でおよそ18秒のズレが生じます。

### UTC
UTC(協定世界時): UTCは世界共通の時刻であり、各地域の標準時はこの`UTC`を基準として算出されています。UTCは原子時をもとに時刻を進めていますが、原子時を人が扱う現実の時間に変換(整数として扱う)するとほんの僅かな誤差が生じます。この誤差の影響が1秒以上になってしまっては問題が生じるため、1秒以上の誤差が生じないように人工的に原子時に`うるう秒`を考慮したものが`UTC`です。

### JST
JST(日本標準時): JSTは日本における時間の標準化を図っており、UTC+9時間と定義されています。 よって`UTC`で1時のものは`JST`だと10時となります。

### UNIX TIME
UNIXタイム: PCなどはインターネットが繋がっていない状態でも時間は進みます。これは1970年1月1日0時0分0秒(UNIXエポック)からの経過時間を算出することで実現しています。UNIXタイムは本当の時刻経過を表しておらず、うるう秒も考慮されませんが多くのシステムでは運用的な問題が少ないため実質的な時刻として扱われています。

## タイムゾーン
時刻は世界中あらゆる場所で使用されます。もし参照する地域によって、時刻がばらばらになってしまっては`時刻`自体が意味のないものとなってしまいます。そこで標準時を基準に、その地域が標準時からどれだけ遅れているか、または、進んでいるかを加えることによって時刻の共通化を図るものがタイムゾーンです。これにより時差の概念が生じます。

## Swiftで日付を扱うときのアンチパターン集 :no_entry: 
ここではSwiftでの日付処理におけるアンチパターンを紹介するとともに次のセクションでは、Swiftでの正しい日付処理の仕方を紹介します。

### 1. Formatterでロケール・タイムゾーンを設定しない
例えばインドのように公用語が母国語ではないケースや、「言語」は英語を設定しているが「地域」は日本など必ずしも現在設定している地域(ロケール)が日付の表示したい言語とは限りません。これについては現在の地域を使用する方法や`preferredLanguages`を使用する方法もありますが、明示的に指定することでUXの向上や正しいローカライズ結果を得ることができます。

### 2. 日付文字列へ変換時にローカライズされるべき箇所をハードコードしている

ダメな例:

```swift
formatter.dateFormat = "yyyy年MM月dd日"
print(formatter.string(from: Date())) // 2023年7月23日
```
この場合`年`や`月`、`日`などローカライズされるべき文字が混合しており、今後ローカライズの対応を検討するときに`Localizable.strings`に国ごとのパターンを個別に追加する未来が見えるためアンチパターンとなります。

正しい例:

```swift
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ydMMM", options: 0, locale: Locale(identifier: "ja_JP"))
print(formatter.string(from: Date())) // 2023年7月23日
```

`年・月・日`はどこから現れたんだろうと思う方もいらっしゃるかもしれませんが、これはテンプレートとロケールを組み合わせることでその地域に合わせた形式での日付の出力が可能となります。

### 3. 曜日の算出を試みる
ダメな例：

```swift
let weeks = ["日","月","火","水","木","金","土"]
// 曜日の算出
let week = weeks[currentIndex] // 日
```

正しい例:

```swift
let formatter = DateFormatter()
formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "EEEEE", options: 0, locale: Locale.current)
print(formatter.string(from: Date())) // 日
```

これは`DateFormatter`のパターンを使用することで解決できます。[パターン一覧はこちら](http://www.unicode.org/reports/tr35/tr35-25.html#Date_Format_Patterns)

### 4. StringからDate変換時に`en_US_POSIX`以外のロケールを指定する

```swift
let now = "2023/8/12"
formatter.dateFormat = "yyyy/MM/dd"
formatter.locale = Locale(identifier: "ja_JP")
let date = formatter.date(from: now)
```

これを実行してみると通常の設定では`date`は正しく`2023/8/12`と取得されます。
しかし、例えばiPhoneの設定から日付を西暦ではなく「和暦」にしてみるとどうでしょう？

すると`4011/8/12`と取得されてしまいます。

これは和暦を設定したために本来は`令和5年`が2023年という解釈ですがこれが、`令和2023年は西暦何年か?`という解釈になってしまい、その結果`西暦4011年`となります。このようにformatterに対して本体設定が解釈に影響を及ぼすコードであることからアンチパターンと言えます。

## Dateの王道 👑

### 王道1. styleを検討すべし
`DateFormatter`には`dateStyle`と`timeStyle`があり、それぞれ日付と時間の出力形式をロケールによりローカライズしたフォーマットとして扱ってくれるプロパティがあります。
日付の出力の仕様がこの`style`の指定で満たされる場合は、個別に`yyyy年MM月dd日`のような固定フォーマットを与えるべきではありません。

使い方:

```swift
let f = DateFormatter()
f.timeStyle = .full
f.dateStyle = .full
f.locale = Locale(identifier: "ja_JP")
let now = Date()
print(f.string(from: now)) // 令和5年7月23日日曜日 11時54分05秒 日本標準時
```

`ja_JP`におけるスタイルチートシート:

|dateStyle|出力|timeStyle|出力|
|:-:|:-:|:-:|:-:
|.full|2017年8月13日日曜日|.full|16時29分05秒 日本標準時|
|.long|2017年8月13日|.long|16:35:54 JST|
|.medium|2017/08/13|.medium|16:36:46|
|.short|2017/08/13|.short|16:37|
|.none|(出力なし)|.none|(出力なし)|

使用例1(日付だけ出力):

```swift
let f = DateFormatter()
f.dateStyle = .long
f.timeStyle = .none
let now = Date()
print(f.string(from: now)) //2023年7月23日
```

使用例2(時刻だけ出力):

```swift
let f = DateFormatter()
f.dateStyle = .none
f.timeStyle = .medium
let now = Date()
print(f.string(from: now)) //16:36:46
```

### 王道2. テンプレートを検討すべし
`DateFormatter.dateFormat(fromTemplate:options:locale)`を使用することでスタイルよりも自由度が高い出力形式の指定が行えます。

使用例:

```swift
extension DateFormatter {
    // テンプレートの定義(例)
    enum Template: String {
        case date = "yMd"     // 2023/1/1
        case time = "Hms"     // 12:39:22
        case full = "yMdkHms" // 2023/1/1 12:39:22
        case onlyHour = "k"   // 17時
        case era = "GG"       // "西暦" (default) or "令和" (本体設定で和暦を指定している場合)
        case weekDay = "EEEE" // 日曜日
    }

    func setTemplate(_ template: Template) {
        // optionsは拡張用の引数だが使用されていないため常に0
        dateFormat = DateFormatter.dateFormat(fromTemplate: template.rawValue, options: 0, locale: .current)
    }
}

// テンプレートから時刻を表示
let f = DateFormatter()
f.setTemplate(.full)
let now = Date()
print(f.string(from: now)) // 2023/7/23 17:24:21
```

テンプレート文字の種類と意味一覧はこちらから確認できます: [DateFieldSymbolTable](http://www.unicode.org/reports/tr35/tr35-25.html#Date_Field_Symbol_Table)

### 王道3: 上記でカバーできないパターンのみ`dateFormat`に頼るべし

`style`と`template`は便利であるものの当然全てのパターンを網羅できるものではありません。例えば時刻を`あと○分`と表示したいという場合にはカバーできません。そういった場合には素直に`dateFormat`を使用します。

また、日本語の場合`時`や`分`などのローカライズされるべき文字がありますが、多くの場合は記号区切りです。
つまり`○時○分`のような記号と一対一で結びつかないローカライズを期待する文字は世界共通ではないのでRFC3339では非推奨とされています。そのためテンプレートなどを使用した場合においても`10:30`のような表記になります。よって、日本語で`あと○分`と表示したい場合には、Localizable.strings(ja)から`あとmm分`を`dateFormat`に与えるのが良いということになります。(Localizable.strings(en)の場合は`mm minutes remaining`)。

### 王道4. StringからDateに変換するときは常に`en_US_POSIX`を指定すべし
次のコードを見てください。これは`Date`を`yyyy/MM/dd`と表示したい場合の例です。

```swift
let f = DateFormatter()
f.dateFormat = "yyyy/MM/dd"
let now = Date()
print(f.string(from: now))
```

結果例:
本体設定が西暦(グレゴリアン)の場合 -> `2023/8/12`
本体設定が和暦の場合 -> `0005/8/12`
本体設定がタイ仏暦の場合 -> `2566/8/12`

このようにある設定では正常に動いていても設定を変更した途端にこのコードは破綻してしまいます。これは`dateFormat`が現在の時刻表示設定を用いてフォーマットの解釈を行うためです。つまり任意のフォーマットを指定する場合、本来全ての設定を網羅した処理が必要となってしまいます。

```swift
if グレゴリアン {...}
else if 和暦 {...}
else if タイ仏暦 {...}
...
```
これはロジックを複雑化させメンテナンス性を下げるコードにシフトしていく未来が見えるためアンチパターンです。

これを解決するために`en_US_POSIX`を使用します。 よく生物の学名がラテン語で表記されているのを見ると思いますが、これはラテン語がこれ以上言語として仕様変更がないとみなされているため採択されたものです。つまり、あとから変更されないことが保証されているため、膨大な生物の名前を一意に与えたいときに便利だから採択されたというわけです。　

このラテン語の例は今回の`en_US_POSIX`を使う理由と同じです。`en_US_POSIX`は仕様変更されないことが保証されている唯一のロケールであるため、`RFC3339に準拠した日付文字列`(例: `yyyy-MM-dd'T'HH:mm:ss'Z'`)をDateに変換できる保証があります。`en_US_POSIX`は`ja_JP`などのロケールとは違い当然、和暦やタイ仏暦などもありません。つまり、いかなる本体設定をしようとも`RFC3339`のフォーマットの日付文字列を正しく解釈することができるということです。そのため一度`en_US_POSIX`で解釈した`Date`オブジェクトから任意の出力形式に変換することによって設定やLocaleに依存しない日付処理を実現することができます。

## Formatterの底力編
### 和暦の"元号名"を文字列で取得する
※「令和」の表記はmacOS Mojave(10.14.5)またはiOS(12.3)以降のバージョンが必要です。

```swift
let day: Double = 60 * 60 * 24
let year: Double = day * 365
let f = DateFormatter()

// 日本語表示
f.locale = Locale(identifier: "ja_JP")

// 和暦表示
f.calendar = Calendar(identifier: .japanese)

// 元号名をつけて出力
f.dateFormat = DateFormatter.dateFormat(fromTemplate: "GydMMMEEE", options: 0, locale: Locale(identifier: "ja_JP"))

// 令和
print(f.string(from: Date())) // 令和5年7月23日(日)

// 平成
print(f.string(from: -(year * 10))) // 平成25年7月25日(木)

// 昭和(30年前)
print(f.string(from: -(year * 30))) // 昭和15年1月9日(火)

// 大正(50年前)
print(f.string(from: -(year * 50))) // 大正9年1月14日(水)

// 明治(70年前)
print(f.string(from: -(year * 70))) // 明治33年1月18日(木)

// 安政(110年前)
print(f.string(from: -(year * 110))) // 安政7年1月28日(土)

// 応仁(502年前) 応仁の乱の時代
print(f.string(from: -(year * 502))) // 応仁3年4月23日(日)

// 天禄(1000年前)
print(f.string(from: -(year * 1000))) // 天禄1年8月26日(金)

// 大化(1326年前) 限界...これ以上昔にすると...
print(f.string(from: -(year * 1326))) // 大化0年11月15日(月)
``` 

### 当日を「今日」、前日を「昨日」などと表示する
```swift
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
f.doesRelativeDateFormatting = true
let now = Date()
print(f.string(from: now)) // 今日
```

`doesRelativeDateFormatting = true`の時の出力対応表:

|国|2日前|1日前|当日|1日後|2日後|
|:-:|:-:|:-:|:-:|:-:|:-:|
|日本|一昨日|昨日|今日|明日|明後日|
|アメリカ|Jul 22, 2023|Yesterday|Today|Tomorrow|Jul 25, 2023|

### 日付の期間を表示する
日付の期間を表示するには`DateIntervalFormatter`を使用します。

```swift
let f = DateIntervalFormatter()
f.dateTemplate = "ydMMMEEE"
f.locale = Locale(identifier: "ja_JP")
let 今日 = Date()
let 明後日 = Date(timeIntervalSinceNow: 60 * 60 * 48)
print(f.string(from: 今日, to: 明後日)) // 2023年7月23日(日)～25日(火)

```

### 秒数を時刻形式に変換
例えば任意の秒数を`○時間○分○秒`と出力したいときがあります。これを自前で実装しようとなると、仮に`3601`秒の場合`１時間1秒`と表示するなど`分`を読み落としたり、`hh:mm:ss`の形式の場合には`hh`以外の1桁は0で埋めて`1:00:01`とするなど複雑なコードが生まれる未来が見えます。
こういった場合は`DateComponentsFormatter`を使うことにより簡単に解決することができます。

```swift
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .positional
formatter.allowedUnits = [.hour, .minute, .second]
print(formatter.string(from: 3601)) // 1:00:01
```

DateComponentsのunitsStyle一覧:

|unitsStyle|出力|
|:-:|:-:|
|.abbreviated|１時間1秒|
|.brief|1時間1秒|
|.full|1時間 1秒|
|.positional|1:00:01|
|.short|1時間 1秒|
|.spellOut|一時間 一秒|

また、`DateComponentsFormatter`では時間以外にも`allowedUnits`を指定することで日時に変換することもでき、さらに`zeroFormattingBehavior`を指定することである単位で値が0になるときの振る舞いも指定することができます。

## まとめ
- 日付処理や文字列からの変換は本当にロジックを書く必要があるかを検討する :point_up:
- 日付・時刻のみの出力には`style`・`template`を活用する :timer: 
- `String`から`Date`に変換する時は常に`en_US_POSIX`を使用する :flag_us: 


## おまけ
`DateFormatter`のインスタンス生成はコストが高いので繰り返し使用が想定される場合はキャッシュしインスタンスを使いまわすことをおすすめします :comet: (e.g. tableView(_:cellForRowAt:)などでDateFormatterをインスタンス化するのはNG)


## 参考
http://qiita.com/gonsee/items/d3fb641914d2ca45e858
http://qiita.com/cotrpepe/items/a3e36fa70afb418c1bbe
http://techlife.cookpad.com/entry/internationalization-and-localization-of-ios-apps
http://www.unicode.org/reports/tr35/tr35-25.html#Date_Format_Patterns
https://developer.apple.com/documentation/foundation/date
https://developer.apple.com/documentation/foundation/dateformatter
https://developer.apple.com/library/content/qa/qa1480/_index.html