[Proposal] Make Foundation.Date conform to Strideable

Any time I change my locale when that locale defaults to a different calendar — rare, but supported, and certainly used at the very least for testing.

Date is (maybe slightly awkwardly named, but too late for that) a point in the timeline, regardless of how people refer to it through a calendar.

2 Likes

Right. You can’t really make the API for advancing dates any simpler than Calendar, or that’s what they would have done in the first place.

Eh, that's not really true. You can absolutely make simpler APIs, as long as you're willing to accept certain costs that come with the abstraction. There are loads of these sorts of wrappers on Github.

Personally, I also believe it's possible to create a general DateTime API that's safer than the Foundation API, which is what I'm exploring in my "Chronology" repo on Github. Unfortunately, with the direction I think it needs to go, I don't believe Swift can handle it so far.

As for @Erica_Sadun's suggestion of a calendar-locked temporal value, I think that's an interesting idea, and is one I'm pursuing in my aforementioned repository. That would enable you to do things like:

calendarDate.stride(by: .weeks(1), through: calendarDate + .years(1))
1 Like

There used to be NSCalendarDate which did that, but it was deprecated (like I said, the calendar API has undergone many revisions. I don't know why they decided this coupling was a bad idea, but I'm sure there was a good reason).

IIRC, the recommended approach after that was to use DateComponents for such things:

DateComponents

A date or time specified in terms of units (such as year, month, day, hour, and minute) to be evaluated in a calendar system and time zone.
...
It is used to specify a date by providing the temporal components that make up a date and time in a particular calendar: hour, minutes, seconds, day, month, year, and so on. It can also be used to specify a duration of time, for example, 5 hours and 16 minutes. A DateComponents is not required to define all the component fields.

From what I understand, the coupling was broken because the implementation changed from being based on custom code to being based on the ICU libraries.

The newer NSDate/NSCalendar/NSDateComponents API is better, but IMO it's still too complex and makes it way too easy to do something subtly but drastically wrong.

1 Like

This implementation falls victim to the "1 + 1 ≠ 2" fallacy.

If you start with Jan 31 and are striding by 1 month, you'll get:

2018-01-31 07:00:00 +0000
2018-02-28 07:00:00 +0000
2018-03-28 06:00:00 +0000
2018-04-28 06:00:00 +0000
2018-05-28 06:00:00 +0000

On the other hand, if you iterate by adding multiples of the original components, you get:

2018-01-31 07:00:00 +0000
2018-02-28 07:00:00 +0000
2018-03-31 06:00:00 +0000
2018-04-30 06:00:00 +0000
2018-05-31 06:00:00 +0000
3 Likes

That's a good point; I think that's why the documentation advises against repeatedly adding the same components to the result date in a loop like this. They advise to use enumerateDates instead.

A good implementation would increment the components (not the date). That would probably come with a big performance cost.

I guess if it was part of Foundation, it could be more efficient because it could use the same code as enumerateDates (not starting from scratch every time you call next()), but you can't turn that block-based API in to a Swift Iterator very easily.

Yes, this is why the documentation makes that points. However, the suggestion to use the enumerateDates method isn't a satisfactory replacement.

The reason, as @itaiferber mentioned above, is that the enumerateDates method is for matching a known set of components. When you're striding through a calendar like this, you don't have a known set of components you're trying to match, but you instead have a delta you're continuously applying.

In the Jan 31 example, what would our "next matching" date components be? If we're iterating by month, then it'd presumably be all the calendar components smaller than a month. For simplicity, let's say that's just the "Day" component. But the "day" component is 31, so if we try to find the next date matching (day = 31), then we'll get Mar 31 and entirely skip anything in February. So the enumerateDates method is also not what we're looking for.

Well, scaling a DateComponents instance by a factor is a constant-time operation. A naïve implementation would be:

extension DateComponents {
    func scaling(by factor: Int) -> DateComponents {
        let s: (Int?) -> Int? = { $0.map { $0 * factor } }
        
        return DateComponents(calendar: calendar,
                              timeZone: timeZone,
                              era: s(era),
                              year: s(year),
                              month: s(month),
                              day: s(day),
                              hour: s(hour),
                              minute: s(minute),
                              second: s(second),
                              nanosecond: s(nanosecond),
                              weekday: s(weekday),
                              weekdayOrdinal: s(weekdayOrdinal),
                              quarter: s(quarter),
                              weekOfMonth: s(weekOfMonth),
                              weekOfYear: s(weekOfYear),
                              yearForWeekOfYear: s(yearForWeekOfYear))
    }
}

And then, all things being equal, presumably the manipulation routines in ICU would spend about the same amount of time adding "13 months" as they would adding "1 month", so I don't see that being more costly either.

Anyway, the point of all of this is to say that it's absolutely interesting to consider what it would take to make Date be Strideable. However, like I said above, this is a rare enough situation that I don't think it needs to be in the Standard Library. You could maybe make an argument for this being in Foundation, but I think it'd be best served in a dedicated DateTime library that can hold your hand and help you avoid all the weird things that go on with human perceptions of time.

That's not exactly what I meant; I meant that enumerateDates probably keeps its internal calculation state around. I don't know if it costs the same to add 1 or 13 months (or 1 or 10,000 years). I've never looked in to ICU's performance guarantees, but I wouldn't assume it. That's why I said the iterator shouldn't need to start from scratch every time you call next(), but re-reading it, my wording wasn't clear. I didn't mean to say that the iterator should use that specific function.

Anyway, I've taken that on-board, and for anybody interested, it is now more of an actual DateComponents iterator.

New version
import Foundation

private extension DateComponents {
  func scaled(by: Int) -> DateComponents {
    let s: (Int?)->Int? = { $0.map { $0 * by } }
    return DateComponents(calendar: calendar,
                          timeZone: timeZone,
                          era: s(era),
                          year: s(year), month: s(month), day: s(day),
                          hour: s(hour), minute: s(minute), second: s(second), nanosecond: s(nanosecond),
                          weekday: s(weekday), weekdayOrdinal: s(weekdayOrdinal), quarter: s(quarter),
                          weekOfMonth: s(weekOfMonth), weekOfYear: s(weekOfYear), yearForWeekOfYear: s(yearForWeekOfYear))
  }
}

extension Calendar {

  func makeIterator(components: DateComponents, from date: Date, until: Date?) -> Calendar.DateComponentsIterator {
    return DateComponentsIterator(calendar: self, startDate: date, cutoff: until, components: components, count: 0)
  }

  func makeIterator(every component: Component, stride: Int = 1, from date: Date, until: Date?) -> Calendar.DateComponentsIterator {
    var components = DateComponents(); components.setValue(stride, for: component)
    return makeIterator(components: components, from: date, until: until)
  }

  struct DateComponentsIterator: IteratorProtocol {
    let calendar: Calendar
    let startDate: Date
    let cutoff: Date?
    let components: DateComponents
    var count: Optional<Int> = 0

    mutating func next() -> Date? {
      guard let count = self.count else { return nil } // Ended.
      guard let nextDate = calendar.date(byAdding: components.scaled(by: count), to: startDate) else {
        self.count = nil; return nil
      }
      if let cutoff = self.cutoff, nextDate > cutoff {
        self.count = nil; return nil
      }
      self.count = count + 1
      return nextDate
    }
  }
}

var iter = Calendar.current.makeIterator(every: .day, from: Date(), until: Date().addingTimeInterval(10_000_000))

IteratorSequence(iter).forEach { print($0) }
1 Like

Imho calendrical dates managing, which is hard, is not what this topic should be about. Even though the discussion strayed quite fast to that, which is common due to the nature of Date, I believe the main point should be extension Date: Strideable. The implementation of this is simple and I think it makes sense for Date to conform.

The only issue here is the fear of misusing this conformance to do calendrical calculations without using Calendar, but afaik people already do this and I'm not sure this conformance would make the issue any bigger: if a developer knows what Date exactly is, they will use correctly. If they don't they might use it incorrectly whether this conformance is present or not.

The benefit of having this conformance is not that big, the only case I see it used in is when defining sets of timestamps: i.e.

let now = Date()
let timestamps = stride(from: now, to: now + 100, by: 5)

This might be more useful in lower level programming than applications.
I can also add that + and - defined on a Date with a TimeInterval have the same ease of misuse, though they were added to Foundation.

In that case why not use TimeInterval directly? It seems closer to what you’re trying to do.

Well, why use Date over TimeInterval in any case then? Date is really just a typed wrapper over a TimeInterval, which is a typedef Double. The only reason Date exists is to give meaning to some specific Doubles (timestamps) and not let Calendar APIs affect all Doubles.
So, yes, this can be done with TimeInterval directly without issues, and then a map on the result will give the same result as doing stride on Date directly, but why not allow it?

Usually we approach our API design from the other side: why allow it / why add it? Given that API in Foundation needs to be supported effectively forever (we have approximately never actually removed an implementation -- even deprecated API must continue to work), we err on the conservative side of adding new functionality.

It's important not only for it to make sense from the point of view of the type system (which seems to be this argument) but also that it conceptually fits in with the other API we have. This is an attempt to avoid confusion for developers who want to do something they perceive as simple, like enumerating dates. In reality, we all know that is a hard problem.

By the way, with respect to the enumerateDates function on Calendar being better expressed as an iterator of some kind -- I'm aware of that and working on it.

3 Likes

I agree, but I think this addition — while not fundamental — should be included.

The main (only?) use-case is when you want to iterate on moments of time that differ by a "fixed" amount of time, where "fixed" means that you don't care about the resulting "date" after the time has elapsed, but how much time went by.
i.e. It's 1st Jan 2010 14:05:01 (random date), and I want the time in an hour (but not specifically 1st Jan 2010 15:05:01, which might be different if some calendrical exceptions happen in that timeframe).

One example use-case might be to have an event fire every 5 minutes (not at every instant where minutes % 5 == 0) until a specific time (this probably needs Calendar to compute) and you want to feed timestamps for when these events will fire in-batch.

func fireEvent() { ... }
let fireUntilDate = ...
stride(from: Date(), to: fireUntilDate, by: 5 * 60)
    .map({ Timer(fire: $0, interval: 0, repeats: false, block: fireEvent) })
    .forEach { RunLoop.main.add($0, forMode: .commonModes) }

Now, I'm not saying this specific example is a good idea, particularly because Timer APIs already have repeating options, but I was using it only because I remembered it having the fireDate option.

I know Date is a bit delicate as in too many people ignore the fallacies and just use seconds for calendrical computations, and making it easier might worry that these usages increase, but why add Date.+ then, which allows more common operations like Date() + (24 * 60 * 60)? (I'm asking this assuming that adding + is not considered a mistake)

Actually, when we introduced struct Date many people believed we should not allow the + operator on it -- but I pushed to keep it for basically the same reason. It is an absolute point in time. It makes total sense to add a number of seconds to it, if what you are looking for is another point in time that is X seconds away from it.

So basically, I'm just looking for a really good use case that is a slam dunk on why we should add the functionality. For example, with respect to the timer, you acknowledge this yourself, but -- why not use repeating timer there? It handles some other edge cases like coalescing repeated timer fires (e.g. if your callout does not run before the timer fires again). So there has to be some other use case which is compelling on its own.

Another better example I can think of is if you have a function that gets Date as an input/data indexed by Dates, if you want to plot it you probably want to stride on it.

This gets a HUGE -1 one from me.

Dates are already complicated enough. We shouldn't make it easier to do wrong things with them. Striding from one instant in time to another instant in time by an absolute number of seconds each step is such a fantastically rare thing to want to actually do that adding this would signal you're supposed to do it this way, which would be Wrong™.

9 Likes

I agree with @davedelong. I don't mind striding by date components but I do mind striding by time intervals.

4 Likes

Another -1 from me. If you’re trying to stride over a number of seconds from a date, to me it’s simple enough to stride from 0 to n and then add the value to your original date inside the stride call.

I’ve seen many beginning programmers struggle mightily with dates and it’s often solving problems like this. While I don’t think Stridable itself would be problematic when used correctly here, I worry that its prominence in the documentation as a protocol Date conforms to would be a siren call leading developers to certain doom when they don’t understand the domain.

1 Like