Calendar の日付列挙を Sequence 化する
Calendar Sequence Enumeration
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
macOS 14 / iOS 17 で Calendar は完全に Swift で書き直されましたが、日付の列挙に関する API は依然として Objective-C 由来の実装をそのまま取り込んだものでした。
Calendar には、特定の条件にマッチする日付や、コンポーネントを加算した日付を列挙するための既存 API として、次のようなクロージャベースのメソッドが用意されていました。
extension Calendar {
public func enumerateDates(
startingAfter start: Date,
matching components: DateComponents,
matchingPolicy: MatchingPolicy,
repeatedTimePolicy: RepeatedTimePolicy = .first,
direction: SearchDirection = .forward,
using block: (_ result: Date?, _ exactMatch: Bool, _ stop: inout Bool) -> Void
)
public func nextDate(
after date: Date,
matching components: DateComponents,
matchingPolicy: MatchingPolicy,
repeatedTimePolicy: RepeatedTimePolicy = .first,
direction: SearchDirection = .forward
) -> Date?
}
これらの API には、Swift で扱う上でいくつかの不便がありました。
enumerateDatesはinoutのstopフラグでループを抜ける作りで、prefixやzipのようなSequence向けのアルゴリズムと組み合わせにくく、「N 件だけ取り出す」「他のシーケンスと並べる」といった処理を書くのに、自前のカウンタや状態管理が必要でした。- 加算による列挙は、単発の結果を返す
date(byAdding:value:to:wrappingComponents:)しか存在せず、「ある開始日から特定の component を順に加算した日付列が欲しい」というユースケースをそのまま表現できませんでした。
加えて、DateComponents には「年内通算日(day of year)」を表すフィールドがありませんでした。たとえば「年初から数えて 234 日目」といった日付の指定や取得は、CoreFoundation の CFCalendarDecomposeAbsoluteTime のような低レベル API を経由しなければ扱えず、FoundationEssentials で Swift だけで ISO8601FormatStyle を実装する上でも障害になっていました。
02 どのように解決されるのか
Calendar に Sequence ベースの列挙 API を追加し、あわせて DateComponents と Calendar.Component に dayOfYear を導入します。いずれも FoundationPreview 0.4 以降で利用できます。
dayOfYear の追加
Calendar.Component のケースと、DateComponents のプロパティとして dayOfYear が追加されます。
extension Calendar {
public enum Component: Sendable {
// 既存のケース ...
case dayOfYear
}
}
extension DateComponents {
/// 年内通算日。
/// たとえばグレゴリオ暦では通常 1〜365、うるう年は 1〜366。
public var dayOfYear: Int?
}
dayOfYear は他の Calendar API ともそのまま組み合わせて使えます。
let cal = Calendar(identifier: .gregorian)
let date = Date(timeIntervalSinceReferenceDate: 682898558.712307) // 2022-08-22 22:02:38 UTC
let dayOfYear = cal.component(.dayOfYear, from: date) // 234
let leapYearDate = cal.date(from: .init(year: 2024, month: 1, day: 1))!
let range1 = cal.range(of: .dayOfYear, in: .year, for: date) // 1..<366
let range2 = cal.range(of: .dayOfYear, in: .year, for: leapYearDate) // 1..<367
// 年内 100 日目は何曜日か?
let whatDay = cal.date(bySetting: .dayOfYear, value: 100, of: .now)!
let dayOfWeek = cal.component(.weekday, from: whatDay) // 3 (Tuesday)
マッチによる日付列挙
enumerateDates に対応する Sequence ベースの API として、dates(byMatching:startingAt:in:matchingPolicy:repeatedTimePolicy:direction:) が追加されます。返り値は some (Sequence<Date> & Sendable) です。
extension Calendar {
public func dates(
byMatching components: DateComponents,
startingAt start: Date,
in range: Range<Date>? = nil,
matchingPolicy: MatchingPolicy = .nextTime,
repeatedTimePolicy: RepeatedTimePolicy = .first,
direction: SearchDirection = .forward
) -> some (Sequence<Date> & Sendable)
}
Sequence を返すため、prefix や zip などの汎用アルゴリズムと自然に組み合わせられます。たとえば、ある時刻以降の毎時 0 分を 3 つだけ取り出して文字列と並べるコードは次のように書けます。
let cal = Calendar(identifier: .gregorian)
let date = Date(timeIntervalSinceReferenceDate: 682869758.712307) // 2022-08-22 07:02:38 PDT
let dates = zip(
cal.dates(byMatching: DateComponents(minute: 0), startingAt: date, matchingPolicy: .nextTime),
["1st period", "2nd period", "3rd period"]
)
let description = dates
.map { "\($0.formatted(date: .omitted, time: .shortened)): \($1)" }
.formatted()
// 8:00 AM: 1st period, 9:00 AM: 2nd period, and 10:00 AM: 3rd period
prefix で件数を区切ることもできます。次の例では、新設された dayOfYear を使って「年内 234 日目」を 5 つ列挙しています。
var matchingComps = DateComponents()
matchingComps.dayOfYear = 234
let result = cal.dates(byMatching: matchingComps, startingAt: date).prefix(5)
/*
Result:
2022-08-22 00:00:00 +0000
2023-08-22 00:00:00 +0000
2024-08-21 00:00:00 +0000 // うるう年: 2 月が 1 日多いので前日にずれる
2025-08-22 00:00:00 +0000
2026-08-22 00:00:00 +0000
*/
range: を渡すと、結果がその範囲に収まる間だけシーケンスが続き、外れた時点で終了します。開始点 startingAt: は range の中でも外でもよく、最初の結果が範囲外なら空のシーケンスが返ります。
let startDate = Date(timeIntervalSinceReferenceDate: 682898558.712307) // 2022-08-22 22:02:38 UTC
let endDate = startDate + (86400 * 3)
var cal = Calendar(identifier: .gregorian)
cal.timeZone = .gmt
var dc = DateComponents()
dc.hour = 22
let result = cal.dates(byMatching: dc, startingAt: startDate, in: startDate..<endDate)
/*
Result:
2022-08-23 22:00:00 +0000
2022-08-24 22:00:00 +0000
2022-08-25 22:00:00 +0000
*/
direction: .backward を渡せば、過去方向の検索もできます。Swift の Range は逆向きにできないため、範囲はそのまま昇順で渡し、開始点を範囲の終端側に取るのが基本パターンです。
let result = cal.dates(byMatching: dc, startingAt: endDate, in: startDate..<endDate, direction: .backward)
/*
Result:
2022-08-25 22:00:00 +0000
2022-08-24 22:00:00 +0000
2022-08-23 22:00:00 +0000
*/
matchingPolicy の .strict を使った場合は、厳密な一致が見つからない時点でシーケンスが終わります。結果の Date の秒は、DateComponents で nanosecond を指定していなければ整数秒に揃えられます。
なお、既存の enumerateDates には「結果がぴったりの一致だったかどうか」を表す exactMatch の Bool が渡されていましたが、Sequence 版ではこの情報は省略され、要素は単なる Date です。実用上ほとんどのコードはこの値を見ていないという調査結果に基づく判断で、この情報が必要な場合は従来の enumerateDates を引き続き使えます。
加算による日付列挙
単発の結果を返す date(byAdding:value:to:wrappingComponents:) に対応する Sequence 版として、Calendar.Component を渡すオーバーロードと DateComponents を渡すオーバーロードの 2 つが追加されます。
extension Calendar {
public func dates(
byAdding component: Calendar.Component,
value: Int = 1,
startingAt start: Date,
in range: Range<Date>? = nil,
wrappingComponents: Bool = false
) -> some (Sequence<Date> & Sendable)
public func dates(
byAdding components: DateComponents,
startingAt start: Date,
in range: Range<Date>? = nil,
wrappingComponents: Bool = false
) -> some (Sequence<Date> & Sendable)
}
value に負の値を渡すと減算になります。wrappingComponents を true にすると、対象の component がオーバーフローしたときに上位の component に繰り上がらず、その component 内で巻き戻ります。
DST(夏時間)境界をまたぐような場合でも、Calendar の加算規則がそのまま適用されます。次の例は、DST の終了でその日が 25 時間ある日を含む 3 日強の範囲で、1 日ずつ加算した結果を列挙したものです。
let startDate = Date(timeIntervalSinceReferenceDate: 689292158.712307) // 2022-11-04 22:02:38 UTC
let endDate = startDate + (86400 * 3) + (3600 * 2)
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(name: "America/Los_Angeles")!
let result = cal.dates(byAdding: .day, startingAt: startDate, in: startDate..<endDate)
/*
Result:
2022-11-05 22:02:38 +0000
2022-11-06 23:02:38 +0000 // DST 当日は 1 時間多い
2022-11-07 23:02:38 +0000
*/
加算版では、進む方向は direction ではなく value や DateComponents の符号で表現します。検索の起点は開始点として独立に渡すため、range だけからは決められず、明示的な startingAt: が必須になっています。
既存 API との関係
これらは既存の enumerateDates や date(byAdding:...) を置き換えるものではなく、追加 API として位置づけられます。利用側のコードに直接の互換性影響はなく、既存コードはそのまま動作します。新しいコードでは、Sequence の合成しやすさを活かせるこれらの API を選ぶのが基本になります。