🕔

Swift6時代の文字列→日時変換

2025/01/23に公開

はじめに

SwiftのDate.ISO8601FormatStyleについて書きます。
これは、iOS15から追加された日時と文字列を変換するためのユーティリティです。StringDateを相互に変換できます。
しばらくApple系OSでしか使えませんでしたが、Swift6で晴れてLinuxなど他OSでも利用できるようになり、実質的にSwiftの新しい標準APIの1つとなりました。

同時に出たAPIに、Date.FormatStyleがあります。似たAPIですが、利用シーンは明確に分かれると思っています。

  • 文字列→日時の変換をするケースは、多くは外部APIから受け取ったデータのパースだと思います。この際、フォーマットとしてISO8601は広く利用されています。
  • 日時→文字列の変換をするケースは、多くがアプリケーションが画面上に日時を表示するためだと思います。この際のフォーマットは、アプリケーションの見た目に合わせて柔軟な形式が求められるでしょう。

このため、ISO8601FormatStyleを文字列→日時変換に使って、FormatStyleは日時→文字列変換で利用されることが多いと思います。

本記事では文字列から日時への変換のみにフォーカスし、ISO8601FormatStyleのみ取り扱います。

使い方

import Foundation

// 通常
let _ = try Date(
    "2021-12-01T10:00:00Z",
    strategy: .iso8601
)

// 浮動小数点あり
let _ = try Date(
    "2021-12-01T10:00:00.000Z",
    strategy: .iso8601.year().month().day().time(includingFractionalSeconds: true).timeZone(separator: .omitted)
)

// タイムゾーン指定を忘れないように注意。忘れると以下のようにズレる
let _ = try Date(
    "2021-12-01T10:00:00.000+09:00",
    strategy: .iso8601.year().month().day().time(includingFractionalSeconds: true)
)
// >> "Dec 1, 2021 at 19:00" in JST
let _ = try Date(
    "2021-12-01T10:00:00.000+09:00",
    strategy: .iso8601.year().month().day().time(includingFractionalSeconds: true).timeZone(separator: .omitted)
)
// >> "Dec 1, 2021 at 10:00" in JST

// Date.ISO8601FormatStyleを直接initする方法でも可
let _ = try Date(
    "2021-12-01T10:00:00.000Z",
    strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)
)

// 日付だけ読む場合
let _ = try Date("2021-12-01", strategy: .iso8601.year().month().day())

.iso8601を呼び出した時点では、デフォルトで日時とタイムゾーンを読み取る設定になっています。
しかし、year() month() day() らを1つでも呼び出した場合、内部的に状態が切り替わり、呼び出したものについてスキャンする方式に変化します。

デフォルトで含まれるコンポーネントは以下です。

https://github.com/swiftlang/swift-foundation/blob/f978f94712d0d395acc0880fb3434d6be61bb501/Sources/FoundationEssentials/Formatting/Date%2BISO8601FormatStyle.swift#L86-L92

ちょっとややこしいですよね。

ISO8601DateFormatterとの違い

以前からあるAPIにISO8601DateFormatterがあります。
こちらは古いAPIで、速度・並行安全性共に劣っています。使える状況であれば積極的に新しいものを利用しましょう。

Sendable 速度 備考
ISO8601DateFormatter × 低速 昔からある
Date.ISO8601FormatStyle ⚪︎ 高速 古い環境では利用できない

簡易ベンチマーク

ベンチマークコード

import Foundation

let iso8601String = "2023-10-09T16:34:23Z"
let iterations = 1000

// ISO8601DateFormatter
let iso8601DateFormatter = ISO8601DateFormatter()
var iso8601DateFormatterTime: TimeInterval = 0
for _ in 0..<iterations {
    let start = Date()
    _ = iso8601DateFormatter.date(from: iso8601String)
    let end = Date()
    iso8601DateFormatterTime += end.timeIntervalSince(start)
}

// Date.ISO8601FormatStyle
var iso8601FormatStyleTime: TimeInterval = 0
for _ in 0..<iterations {
    let start = Date()
    _ = try? Date(iso8601String, strategy: .iso8601)
    let end = Date()
    iso8601FormatStyleTime += end.timeIntervalSince(start)
}

print("ISO8601DateFormatter Time: \(iso8601DateFormatterTime) seconds")
print("Date.ISO8601FormatStyle Time: \(iso8601FormatStyleTime) seconds")

結果:

  • Linuxの場合
$ swift --version
Swift version 6.0.3 (swift-6.0.3-RELEASE)
Target: x86_64-unknown-linux-gnu
$ swift -O test.swift
ISO8601DateFormatter Time: 0.6819443702697754 seconds
Date.ISO8601FormatStyle Time: 0.002825140953063965 seconds
  • macOSの場合
$ swift --version 
Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)
Target: arm64-apple-macosx15.0
$ swift -O test.swift 
ISO8601DateFormatter Time: 0.03178668022155762 seconds
Date.ISO8601FormatStyle Time: 0.0003638267517089844 seconds

ケタ違いに速いことが一目瞭然ですね。

なんで速いの?

ISO8601DateFormatterは裏でC言語のCoreFoundationに処理を委譲しています。
CoreFoundationの中ではおそらく汎用的なICUのカレンダー処理に委譲されているのだと思います。
Date.ISO8601FormatStyleは中身がすべてSwiftで記述されており、パース処理が専用に書き下されています。またカレンダーもグレゴリオ暦のみを前提として特殊化されています。

https://github.com/swiftlang/swift-foundation/blob/f978f94712d0d395acc0880fb3434d6be61bb501/Sources/FoundationEssentials/Formatting/Date%2BISO8601FormatStyle.swift#L510-L819

用途を限定することで高速な処理が記述できているということですね。

よくある落とし穴

浮動小数点のありなしを意識せずにデコードしたい

できません。
ISO8601DateFormatterのときと同様、浮動小数点の有無は厳密に区別されます。
よってありなし両方に対応した処理を記述したければ、異なる設定でパースを2回を走らせる必要があります。

浮動小数点をつける操作が長いよ〜

.iso8601.year().month().day().time(includingFractionalSeconds: true).timeZone(separator: .omitted)も、Date.ISO8601FormatStyle(includingFractionalSeconds: true)も、補完を考慮しても長いですよね。

実はISO8601FormatStyleはRegexとしても機能する上に、Regexとして生成する用のextensionまであります。それを使うと.iso8601WithTimeZone(includingFractionalSeconds: true)として記述できます。

let _: Date? = "2021-12-01T10:00:00.000Z".wholeMatch(
    of: .iso8601WithTimeZone(includingFractionalSeconds: true)
)?.output

ただし、この方法はRegexの処理が挟まってしまうので遅くなってしまいます。私の手元では10倍の時間がかかりました。
素直に自分でエイリアスを生やしたほうが良さそうですね。

Discussion