Swift Digest

Calendar の日付列挙を Sequence 化する

Calendar Sequence Enumeration

Proposal
SF-0001
Authors
Tony Parker
Review Manager
Tina Liu
Status
Accepted

このダイジェストは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 で扱う上でいくつかの不便がありました。

  • enumerateDatesinoutstop フラグでループを抜ける作りで、prefixzip のような Sequence 向けのアルゴリズムと組み合わせにくく、「N 件だけ取り出す」「他のシーケンスと並べる」といった処理を書くのに、自前のカウンタや状態管理が必要でした。
  • 加算による列挙は、単発の結果を返す date(byAdding:value:to:wrappingComponents:) しか存在せず、「ある開始日から特定の component を順に加算した日付列が欲しい」というユースケースをそのまま表現できませんでした。

加えて、DateComponents には「年内通算日(day of year)」を表すフィールドがありませんでした。たとえば「年初から数えて 234 日目」といった日付の指定や取得は、CoreFoundation の CFCalendarDecomposeAbsoluteTime のような低レベル API を経由しなければ扱えず、FoundationEssentials で Swift だけで ISO8601FormatStyle を実装する上でも障害になっていました。

02 どのように解決されるのか

CalendarSequence ベースの列挙 API を追加し、あわせて DateComponentsCalendar.ComponentdayOfYear を導入します。いずれも 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 を返すため、prefixzip などの汎用アルゴリズムと自然に組み合わせられます。たとえば、ある時刻以降の毎時 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 の秒は、DateComponentsnanosecond を指定していなければ整数秒に揃えられます。

なお、既存の enumerateDates には「結果がぴったりの一致だったかどうか」を表す exactMatchBool が渡されていましたが、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 に負の値を渡すと減算になります。wrappingComponentstrue にすると、対象の 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 ではなく valueDateComponents の符号で表現します。検索の起点は開始点として独立に渡すため、range だけからは決められず、明示的な startingAt: が必須になっています。

既存 API との関係

これらは既存の enumerateDatesdate(byAdding:...) を置き換えるものではなく、追加 API として位置づけられます。利用側のコードに直接の互換性影響はなく、既存コードはそのまま動作します。新しいコードでは、Sequence の合成しやすさを活かせるこれらの API を選ぶのが基本になります。