Skip to content

Commit 7566cd4

Browse files
Double-double Date (#1533)
Adapts Date to use a double-Double representation (the underlying representation is the sum of two double-precision values, giving about 106 bits of precision). Previously Date was backed by a single Double measuring time since Jan 1 2001 in seconds. Because Double's precision is non-uniform, this means that times within a hundred days of the epoch are represented with approximately nanosecond precision, but as you get farther away from that date the precision decreases. For times close to today, it has been reduced to about 100ns. The obvious thing would be to adopt an integer-based representation similar to C's timespec (32b nanoseconds, 64b seconds) or Swift's Duration (128b attoseconds). These representations suffer from a few difficulties: - Existing API on Date takes and produces TimeInterval (aka Double). Making Date use an integer representation internally would mean that existing users of the public API would suddently start getting different results for computations that were previously exact; even though we could add new API, and the overall system would be more precise, this would be a surprisingly subtle change for users to navigate. - We have been told that some software interprets the raw bytes of Date as a Double for the purposes of fast serialization. These packages formally violate Foundation's API boundaries, but that doesn't help users of those packages who would abruptly be broken by switching to an integer representation. Using DoubleDouble instead navigates these problems fairly elegantly. - Because DoubleDouble is still a floating-point type, it still suffers from non-uniform precision. However, because DoubleDouble is so fantastically precise, it can represent dates out to ±2.5 quadrillion years at ~nanosecond or better precision, so in practice this won't be much of an issue. - Existing API on Date will produce exactly the same result as it did previously in cases where those results were exact, minimizing surprises. In cases where the existing API was not exact, it will produce much more accurate results, even if users do not adopt new API, because its internal calculations are now more precise. - Software that (incorrectly) interprets the raw bytes of Date as a Double will get at least as accurate of a value as it did previously (and often a more accurate value). DateInterval gets the same treatment, so that it can benefit from more accurate internal computations as well. Follow-on work may adapt/add-to Date's API to make it easier to specify Dates with high precision.
1 parent 59c6a46 commit 7566cd4

File tree

10 files changed

+391
-96
lines changed

10 files changed

+391
-96
lines changed

Sources/FoundationEssentials/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ add_library(FoundationEssentials
2121
ComparisonResult.swift
2222
Date.swift
2323
DateInterval.swift
24+
DoubleDouble.swift
2425
FoundationEssentials.swift
2526
IndexPath.swift
2627
LockedState.swift

Sources/FoundationEssentials/Calendar/Calendar_Gregorian.swift

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1448,30 +1448,39 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
14481448
}
14491449

14501450
func dateInterval(of component: Calendar.Component, for date: Date) -> DateInterval? {
1451-
let time = date.timeIntervalSinceReferenceDate
1451+
let approximateTime = date._time.head
14521452
var effectiveUnit = component
14531453
switch effectiveUnit {
14541454
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
14551455
return nil
14561456
case .era:
1457-
if time < -63113904000.0 {
1457+
if approximateTime < -63113904000.0 {
14581458
return DateInterval(start: Date(timeIntervalSinceReferenceDate: -63113904000.0 - inf_ti), duration: inf_ti)
14591459
} else {
14601460
return DateInterval(start: Date(timeIntervalSinceReferenceDate: -63113904000.0), duration: inf_ti)
14611461
}
14621462

14631463
case .hour:
1464-
let ti = Double(timeZone.secondsFromGMT(for: date))
1465-
var fixedTime = time + ti // compute local time
1466-
fixedTime = floor(fixedTime / 3600.0) * 3600.0
1467-
fixedTime = fixedTime - ti // compute GMT
1468-
return DateInterval(start: Date(timeIntervalSinceReferenceDate: fixedTime), duration: 3600.0)
1464+
// Local hours may not be aligned to GMT hours, so we have to apply
1465+
// the time zone adjustment before rounding down, then unapply it.
1466+
let offset = Double(timeZone.secondsFromGMT(for: date))
1467+
let start = ((date._time + offset)/3600).floor() * 3600 - offset
1468+
return DateInterval(
1469+
start: Date(start),
1470+
duration: 3600
1471+
)
14691472
case .minute:
1470-
return DateInterval(start: Date(timeIntervalSinceReferenceDate: floor(time / 60.0) * 60.0), duration: 60.0)
1473+
return DateInterval(
1474+
start: Date((date._time/60).floor() * 60),
1475+
duration: 60
1476+
)
14711477
case .second:
1472-
return DateInterval(start: Date(timeIntervalSinceReferenceDate: floor(time)), duration: 1.0)
1478+
return DateInterval(start: Date(date._time.floor()), duration: 1)
14731479
case .nanosecond:
1474-
return DateInterval(start: Date(timeIntervalSinceReferenceDate: floor(time * 1.0e+9) * 1.0e-9), duration: 1.0e-9)
1480+
return DateInterval(
1481+
start: Date((date._time*1e9).floor() / 1e9),
1482+
duration: 1e-9
1483+
)
14751484
case .year, .yearForWeekOfYear, .quarter, .month, .day, .dayOfYear, .weekOfMonth, .weekOfYear:
14761485
// Continue to below
14771486
break

Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ extension Calendar {
9393
/// value is used as a lower bound for ``nextBaseRecurrenceDate()``.
9494
let rangeLowerBound: Date?
9595

96-
/// The start date's nanoseconds component
97-
let startDateNanoseconds: TimeInterval
96+
/// The start date's fractional seconds component
97+
let fractionalSeconds: TimeInterval
9898

9999
/// How many occurrences have been found so far
100100
var resultsFound = 0
@@ -131,7 +131,10 @@ extension Calendar {
131131
}
132132
self.recurrence = recurrence
133133

134-
self.start = start
134+
// round start down to whole seconds, set aside fraction.
135+
let wholeSeconds = start._time.floor()
136+
fractionalSeconds = (start._time - wholeSeconds).head
137+
self.start = Date(wholeSeconds)
135138
self.range = range
136139

137140
let frequency = recurrence.frequency
@@ -233,9 +236,7 @@ extension Calendar {
233236
case .monthly: [.second, .minute, .hour, .day]
234237
case .yearly: [.second, .minute, .hour, .day, .month, .isLeapMonth]
235238
}
236-
var componentsForEnumerating = recurrence.calendar._dateComponents(components, from: start)
237-
238-
startDateNanoseconds = start.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1)
239+
var componentsForEnumerating = recurrence.calendar._dateComponents(components, from: start)
239240

240241
let expansionChangesDay = dayOfYearAction == .expand || dayOfMonthAction == .expand || weekAction == .expand || weekdayAction == .expand
241242
let expansionChangesMonth = dayOfYearAction == .expand || monthAction == .expand || weekAction == .expand
@@ -427,11 +428,11 @@ extension Calendar {
427428
recurrence._limitTimeComponent(.second, dates: &dates, anchor: anchor)
428429
}
429430

430-
if startDateNanoseconds > 0 {
431+
if fractionalSeconds != 0 {
431432
// `_dates(startingAfter:)` above returns whole-second dates,
432433
// so we need to restore the nanoseconds value present in the original start date.
433434
for idx in dates.indices {
434-
dates[idx] += startDateNanoseconds
435+
dates[idx] += fractionalSeconds
435436
}
436437
}
437438
dates = dates.filter { $0 >= self.start }

Sources/FoundationEssentials/Date.swift

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,64 @@ public typealias TimeInterval = Double
3434
A `Date` is independent of a particular calendar or time zone. To represent a `Date` to a user, you must interpret it in the context of a `Calendar`.
3535
*/
3636
@available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *)
37-
public struct Date : Comparable, Hashable, Equatable, Sendable {
38-
39-
internal var _time : TimeInterval
37+
public struct Date: Comparable, Hashable, Equatable, Sendable {
38+
/* Date is internally represented as a sum of two Doubles.
39+
40+
Previously Date was backed by a single Double measuring time since
41+
Jan 1 2001 in seconds. Because Double's precision is non-uniform, this
42+
means that times within a hundred days of the epoch are represented
43+
with approximately nanosecond precision, but as you get farther away
44+
from that date the precision decreases. For times close to the time
45+
at which this comment was written, accuracy has been reduced to about
46+
100ns.
47+
48+
The obvious thing would be to adopt an integer-based representation
49+
similar to C's timespec (32b nanoseconds, 64b seconds) or Swift's
50+
Duration (128b attoseconds). These representations suffer from a few
51+
difficulties:
52+
53+
- Existing API on Date takes and produces `TimeInterval` (aka Double).
54+
Making Date use an integer representation internally would mean that
55+
existing users of the public API would suddently start getting
56+
different results for computations that were previously exact; even
57+
though we could add new API, and the overall system would be more
58+
precise, this would be a surprisingly subtle change for users to
59+
navigate.
60+
61+
- We have been told that some software interprets the raw bytes of Date
62+
as a Double for the purposes of fast serialization. These packages
63+
formally violate Foundation's API boundaries, but that doesn't help
64+
users of those packages who would abruptly be broken by switching to
65+
an integer representation.
66+
67+
Using DoubleDouble instead navigates these problems fairly elegantly.
68+
69+
- Because DoubleDouble is still a floating-point type, it still suffers
70+
from non-uniform precision. However, because DoubleDouble is so
71+
fantastically precise, it can represent dates out to ±2.5 quadrillion
72+
years at ~nanosecond or better precision, so in practice this won't
73+
be much of an issue.
74+
75+
- Existing API on Date will produce exactly the same result as it did
76+
previously in cases where those results were exact, minimizing
77+
surprises. In cases where the existing API was not exact, it will
78+
produce much more accurate results, even if users do not adopt new
79+
API, because its internal calculations are now more precise.
80+
81+
- Software that (incorrectly) interprets the raw bytes of Date as a
82+
Double will get at least as accurate of a value as it did previously
83+
(and often a more accurate value). */
84+
internal var _time: DoubleDouble
85+
86+
internal init(_ time: DoubleDouble) {
87+
self._time = time
88+
}
89+
}
4090

91+
@available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *)
92+
extension Date {
4193
/// The number of seconds from 1 January 1970 to the reference date, 1 January 2001.
42-
public static let timeIntervalBetween1970AndReferenceDate : TimeInterval = 978307200.0
94+
public static let timeIntervalBetween1970AndReferenceDate: TimeInterval = 978307200.0
4395

4496
/// The number of seconds from 1 January 1601 to the reference date, 1 January 2001.
4597
internal static let timeIntervalBetween1601AndReferenceDate: TimeInterval = 12622780800.0
@@ -51,17 +103,23 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
51103

52104
/// Returns a `Date` initialized to the current date and time.
53105
public init() {
54-
_time = Self.getCurrentAbsoluteTime()
106+
_time = .init(uncheckedHead: Self.getCurrentAbsoluteTime(), tail: 0)
55107
}
56108

57109
/// Returns a `Date` initialized relative to the current date and time by a given number of seconds.
58110
public init(timeIntervalSinceNow: TimeInterval) {
59-
self.init(timeIntervalSinceReferenceDate: timeIntervalSinceNow + Self.getCurrentAbsoluteTime())
111+
self.init(.sum(
112+
Self.getCurrentAbsoluteTime(),
113+
timeIntervalSinceNow
114+
))
60115
}
61116

62117
/// Returns a `Date` initialized relative to 00:00:00 UTC on 1 January 1970 by a given number of seconds.
63118
public init(timeIntervalSince1970: TimeInterval) {
64-
self.init(timeIntervalSinceReferenceDate: timeIntervalSince1970 - Date.timeIntervalBetween1970AndReferenceDate)
119+
self.init(.sum(
120+
timeIntervalSince1970,
121+
-Date.timeIntervalBetween1970AndReferenceDate
122+
))
65123
}
66124

67125
/**
@@ -71,12 +129,12 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
71129
- Parameter date: The reference date.
72130
*/
73131
public init(timeInterval: TimeInterval, since date: Date) {
74-
self.init(timeIntervalSinceReferenceDate: date.timeIntervalSinceReferenceDate + timeInterval)
132+
self.init(date._time + timeInterval)
75133
}
76134

77135
/// Returns a `Date` initialized relative to 00:00:00 UTC on 1 January 2001 by a given number of seconds.
78136
public init(timeIntervalSinceReferenceDate ti: TimeInterval) {
79-
_time = ti
137+
_time = .init(uncheckedHead: ti, tail: 0)
80138
}
81139

82140
/**
@@ -85,7 +143,7 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
85143
This property's value is negative if the date object is earlier than the system's absolute reference date (00:00:00 UTC on 1 January 2001).
86144
*/
87145
public var timeIntervalSinceReferenceDate: TimeInterval {
88-
return _time
146+
return _time.head
89147
}
90148

91149
/**
@@ -100,7 +158,7 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
100158
- SeeAlso: `timeIntervalSinceReferenceDate`
101159
*/
102160
public func timeIntervalSince(_ date: Date) -> TimeInterval {
103-
return self.timeIntervalSinceReferenceDate - date.timeIntervalSinceReferenceDate
161+
return (self._time - date._time).head
104162
}
105163

106164
/**
@@ -173,9 +231,9 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
173231

174232
/// Compare two `Date` values.
175233
public func compare(_ other: Date) -> ComparisonResult {
176-
if _time < other.timeIntervalSinceReferenceDate {
234+
if _time < other._time {
177235
return .orderedAscending
178-
} else if _time > other.timeIntervalSinceReferenceDate {
236+
} else if _time > other._time {
179237
return .orderedDescending
180238
} else {
181239
return .orderedSame
@@ -184,27 +242,27 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
184242

185243
/// Returns true if the two `Date` values represent the same point in time.
186244
public static func ==(lhs: Date, rhs: Date) -> Bool {
187-
return lhs.timeIntervalSinceReferenceDate == rhs.timeIntervalSinceReferenceDate
245+
return lhs._time == rhs._time
188246
}
189247

190248
/// Returns true if the left hand `Date` is earlier in time than the right hand `Date`.
191249
public static func <(lhs: Date, rhs: Date) -> Bool {
192-
return lhs.timeIntervalSinceReferenceDate < rhs.timeIntervalSinceReferenceDate
250+
return lhs._time < rhs._time
193251
}
194252

195253
/// Returns true if the left hand `Date` is later in time than the right hand `Date`.
196254
public static func >(lhs: Date, rhs: Date) -> Bool {
197-
return lhs.timeIntervalSinceReferenceDate > rhs.timeIntervalSinceReferenceDate
255+
return lhs._time > rhs._time
198256
}
199257

200258
/// Returns a `Date` with a specified amount of time added to it.
201259
public static func +(lhs: Date, rhs: TimeInterval) -> Date {
202-
return Date(timeIntervalSinceReferenceDate: lhs.timeIntervalSinceReferenceDate + rhs)
260+
return Date(lhs._time + rhs)
203261
}
204262

205263
/// Returns a `Date` with a specified amount of time subtracted from it.
206264
public static func -(lhs: Date, rhs: TimeInterval) -> Date {
207-
return Date(timeIntervalSinceReferenceDate: lhs.timeIntervalSinceReferenceDate - rhs)
265+
return Date(lhs._time - rhs)
208266
}
209267

210268
/// Add a `TimeInterval` to a `Date`.
@@ -220,7 +278,6 @@ public struct Date : Comparable, Hashable, Equatable, Sendable {
220278
public static func -=(lhs: inout Date, rhs: TimeInterval) {
221279
lhs = lhs - rhs
222280
}
223-
224281
}
225282

226283
@available(macOS 10.10, iOS 8.0, watchOS 2.0, tvOS 9.0, *)

Sources/FoundationEssentials/DateInterval.swift

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,38 +12,45 @@
1212

1313
/// DateInterval represents a closed date interval in the form of [startDate, endDate]. It is possible for the start and end dates to be the same with a duration of 0. DateInterval does not support reverse intervals i.e. intervals where the duration is less than 0 and the end date occurs earlier in time than the start date.
1414
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
15-
public struct DateInterval : Comparable, Hashable, Codable, Sendable {
15+
public struct DateInterval: Comparable, Hashable, Codable, Sendable {
1616

1717
/// The start date.
18-
public var start : Date
18+
public var start: Date
19+
20+
/// Underlying storage for `duration`
21+
internal var _duration: DoubleDouble
1922

2023
/// The end date.
2124
///
2225
/// - precondition: `end >= start`
23-
public var end : Date {
26+
public var end: Date {
2427
get {
25-
return start + duration
28+
return Date(start._time + _duration)
2629
}
2730
set {
2831
precondition(newValue >= start, "Reverse intervals are not allowed")
29-
duration = newValue.timeIntervalSinceReferenceDate - start.timeIntervalSinceReferenceDate
32+
_duration = (newValue._time - start._time)
3033
}
3134
}
32-
33-
/// The duration.
35+
36+
/// The duration
3437
///
3538
/// - precondition: `duration >= 0`
36-
public var duration : TimeInterval {
37-
willSet {
39+
public var duration: TimeInterval {
40+
get {
41+
_duration.head
42+
}
43+
set {
3844
precondition(newValue >= 0, "Negative durations are not allowed")
45+
_duration = DoubleDouble(uncheckedHead: newValue, tail: 0)
3946
}
4047
}
4148

4249
/// Initializes a `DateInterval` with start and end dates set to the current date and the duration set to `0`.
4350
public init() {
4451
let d = Date()
4552
start = d
46-
duration = 0
53+
_duration = .zero
4754
}
4855

4956
/// Initialize a `DateInterval` with the specified start and end date.
@@ -52,7 +59,7 @@ public struct DateInterval : Comparable, Hashable, Codable, Sendable {
5259
public init(start: Date, end: Date) {
5360
precondition(end >= start, "Reverse intervals are not allowed")
5461
self.start = start
55-
duration = end.timeIntervalSince(start)
62+
_duration = end._time - start._time
5663
}
5764

5865
/// Initialize a `DateInterval` with the specified start date and duration.
@@ -61,7 +68,7 @@ public struct DateInterval : Comparable, Hashable, Codable, Sendable {
6168
public init(start: Date, duration: TimeInterval) {
6269
precondition(duration >= 0, "Negative durations are not allowed")
6370
self.start = start
64-
self.duration = duration
71+
_duration = DoubleDouble(uncheckedHead: duration, tail: 0)
6572
}
6673

6774
/**
@@ -162,6 +169,24 @@ public struct DateInterval : Comparable, Hashable, Codable, Sendable {
162169
public static func <(lhs: DateInterval, rhs: DateInterval) -> Bool {
163170
return lhs.compare(rhs) == .orderedAscending
164171
}
172+
173+
enum CodingKeys: String, CodingKey {
174+
case start = "start"
175+
case duration = "duration"
176+
}
177+
178+
public init(from decoder: Decoder) throws {
179+
let container = try decoder.container(keyedBy: CodingKeys.self)
180+
let start = try container.decode(Date.self, forKey: .start)
181+
let duration = try container.decode(TimeInterval.self, forKey: .duration)
182+
self.init(start: start, duration: duration)
183+
}
184+
185+
public func encode(to encoder: Encoder) throws {
186+
var container = encoder.container(keyedBy: CodingKeys.self)
187+
try container.encode(start, forKey: .start)
188+
try container.encode(duration, forKey: .duration)
189+
}
165190
}
166191

167192
@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)

0 commit comments

Comments
 (0)