Swift Digest

カレンダーサポートの拡充

Expanded calendar support

Proposal
SF-0017
Authors
Dragan Besevic
Review Manager
Tina Liu
Status
Accepted

このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら

01 何が問題だったのか

Foundation の Calendar は、グレゴリオ暦・仏暦・和暦・ヘブライ暦・イスラム暦・ペルシャ暦・中国暦など多くの暦体系を扱えますが、南アジアで使われている多数のカレンダーや、中国の旧暦をベースとするベトナム・韓国のカレンダーには対応していませんでした。これらの暦は、日付計算に Sun(太陽)あるいは Sun と Moon(月)の実際の位置(true position)を用いる astronomical calendar であり、Calendar で扱える既存の暦とは仕組みが異なります。

このうち、ヒンドゥー系のいくつかのカレンダーには、Calendar の既存モデルでは表現できない特徴があります。それは「leap day」と呼ばれる日で、ある日付が連続する 2 日間で同じ年・月・日の数値を持ち得る、というものです。グレゴリオ暦のうるう日(2 月 29 日)が「4 年に 1 度、特定の日付」に発生するのに対し、ヒンドゥー系の leap day は太陽と月の実際の位置によって決まるため、どの日付にも現れる可能性があります。同じ「年・月・日」という 3 つの値だけでは、その日が leap day なのかそうでないのかを区別できないため、日付の比較や検索 API がこの情報を扱えるようにしておかないと、結果が一意に決まりません。

これらのカレンダーを Foundation でそのまま扱えないことは、対象地域のユーザーやアプリにとって不便なだけでなく、Swift で書かれたサーバ・クライアントが地域固有のカレンダー要件に応えられない、という問題でもありました。

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

この提案では、Calendar.Identifier に新しいカレンダーのケースが追加され、Calendar の既存 API はこれらの識別子をそのまま受け付けるように拡張されます。あわせて、leap day を表現するためのフィールドが Calendar.ComponentDateComponents に追加されます。いずれも FoundationPreview 6.2 以降で利用できます。背後の実装は swift-foundation-icu リポジトリ側でも対応されるため、Foundation を使うすべてのプラットフォームでこれらのカレンダーが利用可能になります。

追加されるカレンダー

Calendar.Identifier に次のケースが追加されます。インドで使われる solar / lunisolar カレンダーに加え、中国旧暦をベースとするベトナム・韓国のカレンダーが対象です。

extension Calendar.Identifier {
    @available(FoundationPreview 6.2, *)
    case bangla     // Bangla solar calendar

    @available(FoundationPreview 6.2, *)
    case gujarati   // Gujarati lunisolar calendar

    @available(FoundationPreview 6.2, *)
    case kannada    // Kannada lunisolar calendar

    @available(FoundationPreview 6.2, *)
    case malayalam  // Malayalam solar calendar

    @available(FoundationPreview 6.2, *)
    case marathi    // Marathi lunisolar calendar

    @available(FoundationPreview 6.2, *)
    case odia       // Odia solar calendar

    @available(FoundationPreview 6.2, *)
    case tamil      // Tamil solar calendar

    @available(FoundationPreview 6.2, *)
    case telugu     // Telugu lunisolar calendar

    @available(FoundationPreview 6.2, *)
    case vikram     // Vikram lunisolar calendar

    @available(FoundationPreview 6.2, *)
    case vietnamese // Vietnamese lunisolar calendar

    @available(FoundationPreview 6.2, *)
    case korean     // Korean lunisolar calendar
}

ベトナム暦・韓国暦は、Calendar の既存モデルで表現できる範囲のカレンダーで、特別な追加情報は必要ありません。一方で、インドで使われる新しいカレンダーは leap day を持つため、それを表すための追加対応が入ります。

isRepeatedDay の追加

leap day を扱うために、Calendar.ComponentDateComponentsisRepeatedDay という新しいフィールドが追加されます。連続する 2 日間で同じ年・月・日の数値を持つ日のうち、後者を「繰り返しの日」として isRepeatedDay == true で表します。

public enum Component: Sendable {
    // ...
    @available(FoundationPreview 6.2, *)
    case isRepeatedDay
}

extension DateComponents {
    @available(FoundationPreview 6.2, *)
    public var isRepeatedDay: Bool? { get set }
}

名前については、isLeapDay だとグレゴリオ暦のうるう日と混同されやすく、isAdhikaDay だとヒンドゥー暦の用語に馴染みのない開発者には伝わりにくいため、「日が繰り返される」という事実をそのまま表す isRepeatedDay が選ばれています。

既存コードへの影響

DateComponents を新規に作ったとき、isRepeatedDay の初期値は false です。

var components = DateComponents()
// components.isRepeatedDay == false

既存のグレゴリオ暦などこれまでのカレンダーには leap day がないため、これらのカレンダーで isRepeatedDay を扱う API は単に値を無視します。したがって、これまでのカレンダーだけを使っているコードの挙動は変わりません。

新しいヒンドゥー系カレンダーを使うコードでは、日付の比較や検索の挙動に isRepeatedDay が反映されます。たとえば、

cal.compare(d1, d2)

は、ヒンドゥー系カレンダーでは年・月・日が同じでも isRepeatedDay の値が異なれば等しいとは見なされません。それ以外のカレンダーでは isRepeatedDay は無視されます。

一般原則として、isRepeatedDay は、マッチや検索を行う API において既存の isLeapMonth と同じ流儀で扱われます。

検索系 API での挙動

Calendar の検索系 API、たとえば次のメソッドは isRepeatedDay を考慮して動作します。

public func nextDate(
    after date: Date,
    matching components: DateComponents,
    matchingPolicy: MatchingPolicy,
    repeatedTimePolicy: RepeatedTimePolicy = .first,
    direction: SearchDirection = .forward
) -> Date?

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)

public func dates(
    byAdding components: DateComponents,
    startingAt start: Date,
    in range: Range<Date>? = nil,
    wrappingComponents: Bool = false
) -> some (Sequence<Date> & Sendable)

matchingPolicy.strict の場合のヒンドゥー系カレンダーでの振る舞いは次のとおりです。

  • 起点が leap day 上にある場合
    • components.isRepeatedDay == true のときは、指定した年・月・日(および与えた他のフィールド)が一致し、かつそれ自体も leap day である次の日付を返します。DateComponents に月だけしか指定していなければ「同じ月で leap day となる次の日付」を返し、何も指定しなければ「次に現れる leap day」を返します。
    • components.isRepeatedDay == false のときは、年・月・日が一致するが leap day ではない次の日付を返します。
  • 起点が leap day 上にない場合
    • components.isRepeatedDay == true のときは、年・月・日が一致し、かつ leap day である次の日付を返します。
    • components.isRepeatedDay == false のときは、年・月・日が一致し、leap day ではない次の日付を返します。

direction: .backward を指定したときは、検索方向が過去側になるだけで、leap day の扱いは変わりません。repeatedTimePolicy.first / .last は、isRepeatedDay を含めたすべてのコンポーネントが一致する候補が複数ある場合に、最初の一致と最後の一致のどちらを返すかを切り替えます。