Date component ranges in Swift
16 January 2022
Ever needed to iterate over a list of days or months in Swift? Ever needed to have a random-access collection of those?
The first thing they teach you in the How to not Operate on Dates Horribly Wrong class (aka Calendrical Fallacies) is to forget about using seconds for calendar correct calculations. On the Apple platforms you should be using Foundation's Calendar type instead. You will still write bugs, but hopefully they'll be more interesting than calendar drift caused by leap seconds and days.
To get a list of months you can start with an integer range and do fancy operations with it. Define a few example values to get started:
let now = Date()
let valueRange = 1 ... 120
let calendar = Calendar(identifier: .gregorian)
Making a list of dates corresponding to that valueRange
is easy enough:
let months = valueRange.map {
calendar.date(byAdding: .month, value: $0, to: now)!
}
The force unwrap operator, !
, is uncomfortable there, but it's caused by Calendar's somewhat dodgy API design: because Calendar.Component
contains cases such as calendar
and timeZone
, the calculation function has to return an optional. If you're hard coding .month
, I think using a force unwrap there is quite OK.
Anyway, after that, months
is a nice array of dates and we're ready to go home, right?
Well, sure. If the size of the list really is 120, that's pretty much it. But it wouldn't be much a blog post then. If you start dealing with larger ranges, the list will start to get a little cumbersome. The valueRange
we started with it is an example of where we'd like to end up at: it's just two integers wrapped in a ClosedRange
. If you change it to 1 ... 1200
, it doesn't get any larger in memory. But moments in time are represented with Date
s, which are really just floating point values representing the number of seconds since a point in history, and if you created a range like now ... now.addingTimeInterval(100000)
you'll just get timestamps separated by seconds, it won't conform to RandomAccessCollection
because Date
doesn't conform to Strideable
and you'd run into problems with calendar math if you tried to build something fancier based on that.
So we need something else.
Swift has some helpers for creating sequences, such as, well, the sequence
function, but nothing that builds on ranges, as far as I can tell. But building our own type that wraps a range and adds calendar logic on top of it isn't a big task, once you figure out the necessary protocols. That's what we really want, in the end: logically a lazy map over an integer range, one that doesn't create a list but just maps one integer to a date, while getting the calendar math right. The map
function is out because it builds an array.
We can get started by defining a type:
struct CalendarRange {
let calendar: Calendar
let component: Calendar.Component
let epoch: Date
let values: ClosedRange<Int>
}
We use epoch
as the zero point. It's extremely arguable if it is necessary as a property here; you could just hard code something like Date(timeIntervalSinceReferenceDate: 0)
and that would most probably be just fine. Some people would leave Calendar
out too, just relying on .autoupdatingCurrent
, but that's a an easy way to write untestable code. Adding convenience initializers is permitted.
Now we just need to add the necessary conformances to build up to RandomAccessCollection
. The conformance tower goes like this, starting from the top: RandomAccessCollection
→ BidirectionalCollection
→ Collection
→ Sequence
. We can skip over implementing Sequence
because Collection
gives it us for free:
extension CalendarRange: Collection {
typealias Index = ClosedRange<Int>.Index
var startIndex: Index { self.values.startIndex }
var endIndex: Index { self.values.endIndex }
func index(after i: Index) -> Index { self.values.index(after: i) }
subscript(index: Index) -> Date {
self.calendar.date(
byAdding: self.component,
value: self.values[index],
to: self.epoch
)!
}
}
The idea is simple: delegate everything else to the range but execute the calculation in subscript
. There's again the awkward bang, but there's going to be some variation of it regardless of how you package this up, as long as you're using Calendar
which is the right thing to do. You can dress it up in a preconditionFailure
if you prefer.
Next: add BidirectionalCollection
.
extension CalendarRange: BidirectionalCollection {
func index(before i: Index) -> Index { self.values.index(before: i) }
}
That wasn't extremely complicated. Let's see about RandomAccessCollection
next.
extension CalendarRange: RandomAccessCollection {}
Nice. RandomAccessCollection
doesn't require any extra members, it just places restrictions on what kind of index you can use. The one we inherited from ClosedRange
suits it fine.
Now we can test it. Make a debug function and call it with some values:
func show<T>(_ values: T) where T: RandomAccessCollection {
print("----- values count: \(values.count)")
print(values.startIndex, String(describing: values.first))
print(values.endIndex, String(describing: values.last))
}
show(CalendarRange(calendar: gregorian, component: .month, epoch: Date(), values: -12_000 ... 12_000))
show(CalendarRange(calendar: gregorian, component: .day, epoch: Date(), values: -12_000 ... 12_000))
And hey, it works! It's not exactly fast, though. Running that takes noticeably long. To figure out what's happening with code this simple you don't even need to break out Instruments. Just throw a couple of debug prints in there and you'll discover that count
will call index(after:)
for each index. It'll also probably put Xcode in a state where you have force quit it, but such is the price of science.
What we're observing here is the magic of Swift's default protocol requirement implementations: you get count
for free, but it's the lower common denominator. Sometimes that's the best you can do, and sometimes you can help make it suck less. In this case, improving count
is trivial. ClosedRange
has a perfectly good implementation, and we just need to, once again, delegate to it:
extension CalendarRange {
var count: Int { self.values.count }
}
Now it's blazing fast. It's possible there are other methods it would be beneficial to specialize; I haven't looked that deep into it.
One more thing we could do, if we find ourselves using this kind of thing a lot, is return to that statement earlier about what we need: a range with a lazy mapping function. If we think about it a bit more, in the general case, it wouldn't even have to be a range. In some cases it might be useful to do this with a non-closed Range
or an Array
or some other collection. And as I said, what we're doing in this code is just delegating everything but the subscript
implementation to the wrapped range.
A generalized version of this is not much work:
struct MappedRandomAccess<Wrapped, Output>: RandomAccessCollection
where Wrapped: RandomAccessCollection {
typealias Index = Wrapped.Index
let values: Wrapped
let f: (Wrapped.Element) -> Output
var startIndex: Index { self.values.startIndex }
var endIndex: Index { self.values.endIndex }
func index(after i: Index) -> Index { self.values.index(after: i) }
func index(before i: Index) -> Index { self.values.index(before: i) }
var count: Int { self.values.count }
subscript(index: Index) -> Output { self.f(self.values[index]) }
}
This version requires the conversion to be supplied from outside, but that's easy enough too:
let epoch = Date(timeIntervalSinceReferenceDate: 0)
show(MappedRandomAccess(values: -12_000 ... 12_000) {
calendar.date(
byAdding: .month,
value: $0,
to: epoch
)!
})
The type of that value is MappedRandomAccess<ClosedRange<Int>, Date>
, but you could make that a bit easier to deal with using a typealias.
It's very possible this last generalization step isn't worth it in your code base, even if you need the date stepping functionality; using a closure does have a cost and the type is decidedly pointier than the non-general version, even if you manage to hide some of the time with a typealias. But there is a neatness to it.
Whichever version you decide to go with, you now have a memory efficient method of storing a large number of dates separated by a constant calendar unit. It can come useful sometimes.