DiscreteFormatStyle プロトコル
The DiscreteFormatStyle Protocol
このダイジェストはClaude Opus 4.7 / 4.8によって生成されたものです(License)。原文はこちら↗。
01 何が問題だったのか
Foundation の FormatStyle プロトコルは、日付・時間・数値などのデータをフォーマットするための統一的な仕組みを提供します。一方で、UI 上の時計や残り時間表示のように、入力が継続的に変化し続ける値を FormatStyle でフォーマットして表示し続けたい、というユースケースは少なくありません。
このとき問題になるのが、入力の変化に対して出力(表示文字列)の変化が 離散的 だという点です。たとえば Date.now は 1 秒間に何十万回も値が変わりますが、"9:41am" のような時刻表示は分が変わるまで同じ文字列のままです。にもかかわらず、出力の更新タイミングを FormatStyle の外側から知る手段がないため、これまでは次のような選択を迫られていました。
FormatStyleの内部ロジックを外側で再現し、出力が変わる入力値を自前で計算する。実装の詳細をリバースエンジニアリングする必要があり、内部ロジックの変更に追従しなければならず、保守性が著しく落ちます。- フォーマット対象を限定したうえで、その構成に合わせた粗い更新ロジックを書く。たとえば「分が変わるタイミングに揃えて 1 分ごとに更新する」など。手書きで境界合わせをするとずれや誤差が混入しやすく、エラーが起きやすくなります。
- 十分に短い間隔で再フォーマットを繰り返し、出力が古く見えないようにする。多くの更新で結果が同じ文字列になり、メモリ確保や文字列連結を伴う計算が無駄に走るため、計算資源の浪費になります。
つまり、FormatStyle には「いまの入力値の前後で、出力が変わりうる 次の入力値 はどこか」を問い合わせる API が欠けていました。Date.FormatStyle のように分単位で出力を切り替えるスタイルでも、Duration.UnitsFormatStyle のように単位ごとに丸める設定が絡むスタイルでも、その境界を外から正確に求める標準的な手段がなかったのです。
加えて、Date.RelativeFormatStyle には別の制約がありました。このスタイルは Date.now を暗黙の基準として、入力の Date を「いまから見て」の相対表現にフォーマットします。しかしこの「Date.now への暗黙の依存」のせいで、format(_:) が入力と設定だけで結果が決まる純粋な関数ではなくなっており、上記のような「次に出力が変わる入力値」という問いも意味を持ちません。さらに、未来のある時点で表示するための文字列をいま組み立てたい、といった用途にも使えませんでした。
02 どのように解決されるのか
Foundation に、出力が離散的に変化する FormatStyle のための新しいプロトコル DiscreteFormatStyle と、Date.now への依存を持たない新しい相対日時スタイル Date.AnchoredRelativeFormatStyle が追加されます。すべて FoundationPreview 0.4 以降で利用できます。
DiscreteFormatStyle プロトコル
DiscreteFormatStyle は FormatStyle を継承するプロトコルで、ある入力値の前後にある「離散境界(出力が変わりうる入力値)」を問い合わせるためのメンバーを要求します。
public protocol DiscreteFormatStyle<FormatInput, FormatOutput>: FormatStyle {
func discreteInput(before input: FormatInput) -> FormatInput?
func discreteInput(after input: FormatInput) -> FormatInput?
func input(before input: FormatInput) -> FormatInput?
func input(after input: FormatInput) -> FormatInput?
}
考え方の中心は discreteInput(before:) と discreteInput(after:) です。discreteInput(after: x) は「x より大きい値のうち、フォーマット結果が x のときと違うものを返す最小の入力値」を返します。discreteInput(before:) はその逆方向版です。出力がそれ以上変わらないなどの理由で次の境界が存在しないときは、どちらも nil を返します。
これを使うと、たとえば時計表示は次のように書けます。Date.FormatStyle のような時刻スタイルが DiscreteFormatStyle に適合しているため、「次に表示文字列が変わる時刻」をスタイル自身に問い合わせ、その時刻に合わせて再描画をスケジュールできます。
func updateClock() {
let style = Date.FormatStyle()
let currentInput = Date.now
let output = style.format(currentInput)
render(output)
guard let dateForNextUpdate = style.discreteInput(after: currentInput) else {
// この先で出力が変わる入力値が存在しない(極端に大きな日付などのケース)
return
}
scheduleNextUpdate(at: dateForNextUpdate)
}
Duration.UnitsFormatStyle のように丸めが絡むスタイルでも、出力が変わる境界を直接問い合わせられます。
let style = Duration.UnitsFormatStyle(allowedUnits: [.minutes, .seconds], width: .wide)
style.format(.seconds(3)) // "3 seconds"
style.discreteInput(before: .seconds(3)) // .seconds(2.49999999999)
style.format(.seconds(2.49999999999)) // "2 seconds"
style.discreteInput(after: .seconds(3)) // .seconds(3.5)
style.format(.seconds(3.5)) // "4 seconds"
「ほとんどの入力で」の意味
discreteInput(before:) / discreteInput(after:) の定義は厳密には「ほとんどの 入力 x について、出力が変わる隣の入力値を返す」となっています。たとえば浮動小数点値を整数にフォーマットするスタイルでは、floor(x + 1) を返すのが自然な実装ですが、ゼロ方向への丸めを使う場合、区間 (-1, 1) がすべて 0 にフォーマットされます。負の値からの呼び出しに対して 0 を返してしまっても、実装としては許容されます。実装側は、性能と精度のトレードオフを見ながら「ほとんど」の妥当な定義を選ぶことになります。
そのため、すべての離散境界を順に列挙したい場合は、隣り合う出力が同じ文字列になるケースを呼び出し側で取り除く必要があります。プロトコルの解説には、この重複除去を行いながらある範囲の入力/出力ペアをすべて列挙する例も示されています。
input(before:) / input(after:) の役割
input(before:) / input(after:) は、FormatInput あるいはスタイルが内部で扱う表現において、与えられた値のすぐ隣にある別の値を返します。FloatingPoint なら nextUp / nextDown、FixedWidthInteger なら ±1、Date なら timeIntervalSinceReferenceDate 上での nextUp / nextDown、Duration なら ±1 アト秒という形で、これらの型に対する既定の実装が用意されます。
これらは多くの利用者にとっては気にしなくてよいメンバーですが、次のような用途で重要になります。
- ICU ベースの
Date系スタイルなど、内部表現の差で「Dateとしては別の値だが、フォーマットすると区別できない」状況がありうるスタイルでは、discreteInput(after:)が返す境界が必ずしも完全に正確ではありません。input(before:)/input(after:)を使えば、その境界がどの程度の幅にあるのかを呼び出し側で確認し、必要なら高頻度で再フォーマットする区間を限定できます。 - 設定によっては適合できないようなスタイルでは、
input(before:)/input(after:)で常にnilを返すことで「実質的に非適合」であることを表現できます。 - 既存の
DiscreteFormatStyleをラップして条件付きの境界を加える、といった汎用的な実装にも使えます。たとえば「ある値以下では別表示にし、それ以外は既存スタイルを使う」ようなスタイルは、内部的にbase.input(after: x)を呼び出すことで、自身のdiscreteInput(after:)を実装できます。
これらの位置関係は、プロトコルのドキュメントコメントに次のような図で示されています。
xB = discreteInput(before: y) y xA = discreteInput(after: y)
| | |
<-----+---+-------------------------+-------------------------+---+--->
| |
zB = input(after: xB) zA = input(before: xA)
zB...zA の範囲では出力が format(y) と等しいことが保証され、xB 以下や xA 以上では「ほとんどの場合」異なる出力になります。xB と zB の間、zA と xA の間(端を除く)は、出力が予測できない領域として扱われます。
既存の FormatStyle への適合
次の FormatStyle 群が DiscreteFormatStyle に適合します。これらの FormatInput はすべて Comparable なので、境界の意味は前述のとおり Comparable の順序に沿った形になります。
Duration.UnitsFormatStyle/Duration.UnitsFormatStyle.AttributedDuration.TimeFormatStyle/Duration.TimeFormatStyle.AttributedDate.FormatStyle/Date.FormatStyle.AttributedDate.VerbatimFormatStyle/Date.VerbatimFormatStyle.AttributedDate.ISO8601FormatStyle
Date.ComponentsFormatStyle と isPositive
Date.ComponentsFormatStyle は FormatInput が Range<Date> で、Comparable ではありません。さらに Range の制約から lowerBound <= upperBound しか扱えず、「未来のイベントまでの距離」のように upperBound を固定したまま lowerBound を動かす表示には素直に対応できませんでした。
これを解決するため、Date.ComponentsFormatStyle には新しい可変プロパティ isPositive が追加され、DiscreteFormatStyle への適合がこれに合わせて定義されます。
extension Date.ComponentsFormatStyle: DiscreteFormatStyle {
public var isPositive: Bool { get set }
public func discreteInput(before input: Range<Date>) -> Range<Date>?
public func discreteInput(after input: Range<Date>) -> Range<Date>?
public func input(before input: Range<Date>) -> Range<Date>?
public func input(after input: Range<Date>) -> Range<Date>?
}
isPositive が true(既定)のときは、入力範囲を lowerBound → upperBound の正の長さとして扱い、discreteInput(before:) / discreteInput(after:) は lowerBound を固定したまま upperBound を動かして次の境界を返します。false のときは、入力範囲を upperBound → lowerBound の負の長さ(出力にマイナス符号が付く向き)として扱い、upperBound を固定したまま lowerBound を動かします。false では「lowerBound を before(小さく)すると距離が広がる」「after(大きく)すると距離が縮まる」という対応関係になります。
let style = Date.ComponentsFormatStyle(style: .wide)
print(style.format(start..<end)) // "1 hour"
guard let next = style.discreteInput(after: start..<end) else { return }
print(style.format(next)) // "1 hour, 1 second"
Date.AnchoredRelativeFormatStyle
Date.RelativeFormatStyle は Date.now への暗黙の依存があるため、原則として DiscreteFormatStyle には適合できません。代わりに、Date.now への依存を取り除いた新しいスタイル Date.AnchoredRelativeFormatStyle が追加されます。
extension Date {
public struct AnchoredRelativeFormatStyle: Codable, Hashable, Sendable {
public typealias Presentation = Date.RelativeFormatStyle.Presentation
public typealias UnitsStyle = Date.RelativeFormatStyle.UnitsStyle
public typealias Field = Date.RelativeFormatStyle.Field
public var anchor: Date { get set }
public var presentation: Presentation { get set }
public var unitsStyle: UnitsStyle { get set }
public var capitalizationContext: FormatStyleCapitalizationContext { get set }
public var locale: Locale { get set }
public var calendar: Calendar { get set }
public var allowedFields: Set<Field> { get set }
public init(
anchor: Date,
presentation: Presentation = .numeric,
unitsStyle: UnitsStyle = .wide,
locale: Locale = .autoupdatingCurrent,
calendar: Calendar = .autoupdatingCurrent,
capitalizationContext: FormatStyleCapitalizationContext = .unknown
)
public init(
anchor: Date,
allowedFields: Set<Field>,
presentation: Presentation = .numeric,
unitsStyle: UnitsStyle = .wide,
locale: Locale = .autoupdatingCurrent,
calendar: Calendar = .autoupdatingCurrent,
capitalizationContext: FormatStyleCapitalizationContext = .unknown
)
public func format(_ input: Date) -> String
public func locale(_ locale: Locale) -> Self
}
}
extension Date.AnchoredRelativeFormatStyle: DiscreteFormatStyle {
public func discreteInput(before input: Date) -> Date?
public func discreteInput(after input: Date) -> Date?
}
anchor は「文章として参照される日時」、FormatInput として渡す Date は「どの時点から見るか(基準時刻)」です。表示更新の文脈では、参照される日時 anchor は固定で、基準時刻が時間とともに変わっていく、というモデルになります。Date.RelativeFormatStyle で Date.now が担っていた役割を、明示的な FormatInput に置き換えた形です。
Date.RelativeFormatStyle と同じ出力を、Date.now を基準時刻として渡せば再現できます。
launchDate.formatted(.relative(presentation: .named))
Date.now.formatted(.relativeReference(to: launchDate))
このモデル化により、DiscreteFormatStyle の境界も「次にどの基準時刻になったら表示が変わるか」という、表示更新側がほしい意味の値として返せるようになります。さらに、未来のある時点で表示するための文字列を、その時点の基準時刻を渡してあらかじめ組み立てる、といった使い方もできるようになります。
func stringToBeDisplayedNow() -> String {
return Date.RelativeFormatStyle().format(anchor)
}
func stringToBeDisplayed(at referenceDate: Date) -> String {
return Date.AnchoredRelativeFormatStyle(anchor: anchor).format(referenceDate)
}
03 今後の見通し
将来的な方向性として、NumberFormatStyleConfiguration をベースとする FormatStyle 群(数値系の各種スタイル)にも DiscreteFormatStyle 適合を広げることが構想として挙げられています。これらのスタイルは時間に直接関係しないものの、ライブセンサーの値などを継続的にフォーマットして表示するような場面では同じ問題(更新タイミングを外側から知りたい)が発生するため、共通の設定・ロジックを使うこれらのスタイルに同じ仕組みを与えるのは自然な拡張です。
なお、ここで述べたのは将来の構想であり、その実現を約束するものではありません。実際の追加は別の Proposal として議論されることになります。