📅

[SF-0001] Calendar Sequence Enumeration の解説

2023/12/29に公開

この記事はSwiftWednesday Advent Calendar 2023の21日目の記事です。
昨日は @sugiy さんの「矯正治療 - アプリ開発編2」でした。

はじめに

macOS 14およびiOS 17において、CalendarクラスがSwiftに書き換えられました。この更新により、従来のenumerateDatesメソッドと比較して、Swiftの特性を活かした、より直感的で使いやすいCalendar APIの開発が可能となりました。

この記事では、最近提案された [SF-0001] Calendar Sequence Enumeration に焦点を当てています。(この提案は、Calendar APIの機能拡張と使い勝手の向上に関する内容を含んでいます。)特に、追加された3つのメソッドと1つのプロパティに注目し、それらが従来の実装とどのように異なるか、その利点を具体的な例とともに解説します。

新しいCalendar APIのメソッドと機能の詳細

このセクションでは、Calendar APIに新たに追加された3つの主要な機能に焦点を当てます。これらの機能は日付操作をより効率的かつ直感的に行えるように設計されています。

プロポーザルでは、日付操作をシンプルかつ効率的に行うための3つの新しいメソッドが含まれています。これらのメソッドは、日付の加算、マッチング、および日付Sequenceの生成を容易にします。具体的には以下の通りです

1. 日付の加算を行うメソッド

// DateComponentsを使用して日付に加算するメソッド
public func dates(
  byAdding components: DateComponents,
  startingAt start: Date,
  in range: Range<Date>? = nil,
  wrappingComponents: Bool = false
) -> some (Sequence<Date> & Sendable)

// 単一のCalendar.Componentを使用して日付に加算するメソッド
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)

これらのメソッドは、例えば「今から3日後までの毎日の日付」や「今から1時間ごとの次の5時間の日付」など、特定の期間にわたる日付のSequenceを生成する際に便利です。

既存のAPIにdate(byAdding:to:options:)が存在しますがこれとは異なります。

2. 特定の条件にマッチする日付を見つけるメソッド
指定されたDateComponentsにマッチする日付のSequenceを生成します。

// 指定されたDateComponentsにマッチする日付の`Sequence`を返すメソッド
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)

このメソッドは、特定の時間(例:毎時0分)、特定の日(例:毎月1日)、または特定の曜日(例:毎週月曜日)にマッチする日付を見つける際に特に有用です。

この記事では、便宜上これらの新しいメソッドを「新しいbyAddingメソッド」と「新しいbyMatchingメソッド」と称します。

3. 年の中の特定の日(dayOfYear)を扱う新しいプロパティとケース
新しいdayOfYearプロパティとケースは、年の中で特定の日を識別しやすくします。これにより、特定の日付が年の何日目にあたるかを簡単に判断できるようになります。

// Calendar.Componentに新しいcaseとしてdayOfYearが追加される
extension Calendar {
  public enum Component : Sendable {
    // ...

    case dayOfYear
  }
}
// DateComponentsにもdayOfYearプロパティが追加される
extension DateComponents {
  // ...
  
  public var dayOfYear: Int?
}

このプロパティ及びケースの導入により、年の中で特定の日(例:年の100日目や250日目)を指定したり、その日が何曜日であるかを簡単に特定することが可能になります。これは、年間イベントの計画や、特定の日付に関連するデータ分析など、多くのiOSアプリで役立ちます。

新しいbyAddingメソッドについて

新しいbyAddingメソッドは、指定された日付コンポーネントを開始日付に加算し特定の日付コンポーネントを初期日付に加え、連続する日付のSequenceを生成します。このメソッドは、日付計算を簡素化し、開発者がより直感的に日付を操作できるようにします。

従来の実装との比較

従来の日付計算は、繰り返しの処理や複雑なロジックを必要としていました。例えば、現在の時刻から1時間ごとに次の3時間の日付を計算する場合、以下のような実装が考えられます。(enumerateDatesを利用せずにwhile等を利用して実装することも可能です)

func nextThreeHourlyDatesWithEnumrate() {
  let startDate = Date.now
  let components = Calendar.current.dateComponents(
    [.hour, .minute, .second], 
    from: startDate
  )
  var result: [Date] = []
  
  Calendar.current.enumerateDates(
    startingAfter: startDate,
    matching: components,
    matchingPolicy: .nextTime
  ) { (date, _, stop) in
    if let date = date, result.count < 3 {
      result.append(date)
    } else {
      stop = true
    }
  }
  
  print(startDate)
  // 2023-12-29 02:40:26 +0000
  print(result)
  // [2023-12-30 02:40:26 +0000, 
  //  2023-12-31 02:40:26 +0000, 
  //  2024-01-01 02:40:26 +0000]
}

このコード例では、ループ内でstopフラグを管理し、配列の長さを監視する必要があるため、コードの複雑さが増し、可読性が低下しています。

新しいbyAddingメソッドの利点

新しいbyAddingメソッドを使うと、同じ操作がはるかに簡潔になります。

func nextThreeHourlyDates() {
  let startDate = Date.now
  
  let result = Calendar.current.dates(
    byAdding: .hour,
    startingAt: startDate
  ).prefix(3)
  
  print(startDate)
  // 2023-12-29 02:40:26 +0000
  print(Array(result))
  // [2023-12-30 02:40:26 +0000, 
  //  2023-12-31 02:40:26 +0000, 
  //  2024-01-01 02:40:26 +0000]
}

ここでは、新しいbyAddingメソッドで1時間ずつ加算された日付のSequenceを生成し、.prefix(3)を使って最初の3つの日付だけを取得しています。この方法は、コードが読みやすく、効率的です。

複雑な加算の例

さらに複雑な加算が必要な場合、DateComponentsを引数とするbyAddingメソッドを使用することが有効です。例として、現在の時刻から1時間、1分、1秒ずつ加算を繰り返す場合を考えます。

func complexDateComponentsAddition() {
  let startDate = Date.now
  let endDate = startDate + (3600 * 4) // 4時間後
  let dateComponents = DateComponents(
    hour: 1,
    minute: 1,
    second: 1
  )
  
  let result = Calendar.current.dates(
    byAdding: dateComponents,
    startingAt: startDate,
    in: startDate..<endDate
  )
  
  print(startDate)
  //  2023-12-28 14:39:54 +0000
  print(Array(result))
  // [2023-12-28 15:40:55 +0000, 
  //  2023-12-28 16:41:56 +0000, 
  //  2023-12-28 17:42:57 +0000]
}

このコードでは、出力から1時間、1分、1秒が順次加算されていることがわかります。ただし、新しいbyAddingメソッドを使用する際には、in:パラメータで指定された範囲のendDateが含まれないことに注意が必要です。また、このパラメータを省略すると、メソッドは終了しないため、範囲の指定が重要です。

新しいbyAddingメソッドの戻り値の型

以下のsequenceDateの型はsome Sendable & Sequence<Date>となります。
これはSwiftの型システムにおける2つの概念、「不透明型(Opaque Type)」と「プロトコル合成(Protocol Composition)」を組み合わせたものです。
Sendableが付与されていることにより変数が複数のスレッド間で安全に送信できるようになります。

let sequenceDate: some Sendable & Sequence<Date> = Calendar.current.dates(
  // ...
)

sequenceDateの型がsome Sendable & Sequence<Date>となるのは、新しいbyAddingメソッドの戻り値の型がsome (Sequence<Date> & Sendable)であるからです。

新しいbyMatchingメソッドについて

新しいbyMatchingメソッドはメソッドを使えば、指定したコンポーネントにマッチする日付のSequenceを簡単に作成できます。これは、特定の日付パターン(例えば毎週の特定の曜日や毎月の特定の日など)にマッチする日付を効率的に見つける際に非常に有用です。

従来の実装との比較

以前は、特定のパターンにマッチする日付を見つけるためには、手動での繰り返し処理が必要でした。例えば、毎時0分にマッチする日付を見つけるためには、以下のようなコードが必要でした。(enumerateDatesを利用しても実現可能です。)

func nextThreeHourlyDates() {
  let startDate = Date.now
  let dateComponents = DateComponents(minute: 0, second: 0)
  
  var nextDate = startDate
  var result: [Date] = []
  
  while let matchingDate = Calendar.current.nextDate(
    after: nextDate,
    matching: dateComponents,
    matchingPolicy: .nextTime
  ) {
    result.append(matchingDate)
    if result.count > 3 { break }
    nextDate = matchingDate
  }
  
  print(startDate)
  // 2023-12-29 02:40:26 +0000
  print(result)
  //  [2023-12-29 03:00:00 +0000,
  //   2023-12-29 04:00:00 +0000, 
  //   2023-12-29 05:00:00 +0000, 2023-12-29 06:00:00 +0000]
}

このコードは、ループを使って次のマッチする日付を繰り返し検索しています。これには複数の手順と状態管理が必要で、コードが複雑になりがちです。

新しいbyMatchingメソッドの利点

新しいbyMatchingメソッドの導入により、このプロセスが大幅に簡素化されます。

func hourlyDatesAtZeroMinute() {
  let startDate = Date.now
  let dateComponents = DateComponents(
    minute: 0,
    second: 0
  )
  
  let result = Calendar.current.dates(
    byMatching: dateComponents,
    startingAt: startDate,
    matchingPolicy: .nextTime
  ).prefix(3)
  
  print(startDate)
  // 2023-12-29 02:40:26 +0000
  print(Array(result))
  // [2023-12-29 03:00:00 +0000,
  //  2023-12-29 04:00:00 +0000, 
  //  2023-12-29 05:00:00 +0000]
}

このメソッドでは、指定したコンポーネントにマッチする日付のSequenceを直接生成します。例えば、毎時0分にマッチする日付を見つける場合、単に新しいbyMatchingメソッドを使用して、条件にマッチする日付のSequenceを取得するだけです。

ユースケース

新しいbyMatchingメソッドは、特定の時間帯や曜日に繰り返されるイベントのスケジューリング、毎月特定の日に行われるタスクのリマインダーの設定、特定の時間帯にマッチするデータの分析など、さまざまなユースケースに適用できます。例えば、毎週月曜日の9時にマッチする日付のSequenceを生成する場合、以下のように簡単に記述できます。

func mondayDates() -> some Sendable & Sequence<Date> {
  let startDate = Date.now
  let components = DateComponents(
    hour: 19,
    weekday: 2
  )
  let mondayDates = Calendar.current.dates(
    byMatching: components,
    startingAt: startDate,
    matchingPolicy: .nextTime
  )
  
  return mondayDates
}

このように、新しいbyMatchingメソッドは、日付の検索と生成をより効率的で読みやすいものにし、より柔軟かつ効率的に日付に関連するタスクを処理できるようになります。

期間指定と出力順序の設定

特定の時間パターンにマッチする日付を見つけ、その結果を新しい順(逆順)で得ることもできます。
例えば、毎時0分にマッチする日付を3時間分探し、新しい順で結果を取得するには以下のように記述します。

func reverseOrderHourlyDatesAtZeroMinute() {
  let startDate = Date.now
  let endDate = startDate + (3600 * 3) + 1 // 3時間1秒後
  let dateComponents = DateComponents(minute: 0)
  
  let result = Calendar.current.dates(
    byMatching: dateComponents,
    startingAt: endDate,
    in: startDate..<endDate,
    matchingPolicy: .nextTime,
    direction: .backward
  )
  
  print(startDate)
  // 2023-12-29 02:40:26 +0000
  print(Array(result))
  // [2023-12-29 05:00:00 +0000,
  //  2023-12-29 04:00:00 +0000, 
  //  2023-12-29 03:00:00 +0000]
}

ここでのin:パラメータによって、特定の期間(startDateからendDateまで)を指定しています。加えて、.backwardオプションを用いることで、日付のSequenceを逆順(新しい順)で取得します。この場合、startingAtのパラメータはendDateであることが重要です。これにより、期間内の最新の日付から遡って、条件に合致する日付を探索します。

この機能は、特定の期間内で最新のイベントやデータを逆順に確認したい場合など、さまざまなシナリオで有効です。利用者は期間を自由に設定し、指定した条件に合致する日付を簡単かつ柔軟に取得できます。

directionの指定はは新しいbyAddingメソッドでも可能です

dayOfYearプロパティとケースの活用

dayOfYearプロパティとケースは、ある特定の日付がその年の中で何日目にあたるかを示します。このプロパティの導入により、日付に関連する計算がより直感的かつ柔軟に行えるようになりました。

今日が年の何日目かの取得

これまでは、年の中での特定の日付の位置を求めるには、ordinality(of:in:for:)メソッドを使用していました。このメソッドは、.day.yearを指定する必要があり、戻り値がオプショナル型でした。

let dayOfYear: Int? = Calendar.current.ordinality(
  of: .day, 
  in: .year, 
  for: Date.now
)

print(dayOfYear) // 363

新しいdayOfYearcomponent(_:from:)メソッドを組み合わせることで、以下のようによりシンプルに実装できます。戻り値もオプショナル型ではなくなります。

let dayOfYear: Int = Calendar.current.component(
  .dayOfYear, 
  from: Date.now
)

print(dayOfYear) // 363

年の特定の日を取得

例えば、今年の100日目が何日であるかを知りたい場合、従来は年の初めから99日を加算する方法を取っていました。

func dateOfHundredthDayOfCurrentYear() {
  let calendar = Calendar.current
  let currentYear = calendar.component(.year, from: Date.now)
  let startOfYear = calendar.date(from: DateComponents(year: currentYear))!
  
  let result = calendar.date(
    byAdding: DateComponents(day: 99),
    to: startOfYear
  )
  
  print(result) // 2023-04-09 15:00:00 +0000
}

新しいbySettingメソッドを使用すると、この計算がより明確かつ簡潔になります。ここでは、年の初めを取得した後、dayOfYearプロパティを用いて直接100日目の日付を求めます。

func dateOfHundredthDayOfCurrentYear() {
  let calendar = Calendar.current
  let currentYear = calendar.component(.year, from: Date.now)
  let startOfYear = calendar.date(from: DateComponents(year: currentYear))!
  
  let result = calendar.date(
    bySetting: .dayOfYear,
    value: 100,
    of: startOfYear
  )
  
  print(result) // 2023-04-09 15:00:00 +0000
}

このように、dayOfYearプロパティとケースの導入は、年内の特定の日付を取得する際のコードをより直感的かつ効率的にします。

まとめ

この記事では [SF-0001] Calendar Sequence Enumeration のプロポーザルについて詳しく見てきました。このプロポーザルは、macOS 14iOS 17のアップデートでSwiftに書き換えられたCalendarクラスに基づいています。重要な点は以下の通りです。

  1. 新しいAPIの導入
    • 以下の3つのメソッドにより、日付に関する操作が簡単かつ効率的に行えるようになりました。これにより、開発者は繰り返し処理や複雑な日付計算をシンプルに実装できるようになります。
      1. dates(byAdding:value:startingAt:in:wrappingComponents:)
      2. dates(byAdding:startingAt:in:wrappingComponents:)
      3. dates(byMatching:startingAt:in:matchingPolicy:repeatedTimePolicy:direction:)
  2. 利便性と効率の向上
    • 新しいAPIは、Sequenceベースのアプローチを採用しており、Swiftの標準ライブラリとの互換性が高いです。これにより、開発者はより読みやすく、保守しやすいコードを書くことが可能になります。
  3. dayOfYearプロパティとケースの導入
    • このプロパティとケースにより、年内の特定の日付をより簡単に取得できます。これは、特に日付に関連する複雑な計算を必要とするアプリケーションにとって大きな利点です。

このプロポーザルは、日付と時間に関する操作を行うSwift開発者にとって大きなメリットをもたらします。日付の計算や繰り返し処理は、多くのアプリケーションで一般的なタスクであり、このプロポーザルにより、これらのタスクの実装がより簡単かつ効率的になります。

また、プロポーザルのレビュー期間はすでに終了しており、最新の情報は公式のプロポーザルやフォーラムで確認することが推奨されます。このアップデートにより、日付と時間に関連する開発作業を大きく前進させることでしょう。

開発環境

  • Swift compiler version info:
    • Apple Swift version 5.9 (swiftlang-5.9.0.128.108 clang-1500.0.40.1)
  • Xcode version info:
    • Xcode 15.0 Build version 15A240d
  • Calendar Sequence Enumeration info:

参考引用文献

DeNA Engineers

Discussion