Date component ranges in Swift

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 Dates, 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: RandomAccessCollectionBidirectionalCollectionCollectionSequence. 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.

© Juri Pakaste 2024