Happy Fun Leopard Bug Time: NSCalendar
Yes, folks, it’s Happy Fun Bug Time where I talk about what has been making me tear my hair out recently. In this installment, we talk about NSCalendar.
NSCalendar has a method called rangeOfUnit:inUnit:forDate:
. What this method is supposed to do calculate how many of one time unit are in another. For instance, how many days are in a particular month. Since date calculations can get tricky, with daylight savings, leap years and all, this method is quite handy.
Or, it would be handy if it didn’t suffer from some pretty bad bugs on Leopard. At first, I tried something like the following to calculate the number of days in a given year:
range = [[NSCalendar currentCalendar] rangeOfUnit:NSDayCalendarUnit inUnit:NSYearCalendarUnit forDate:[NSDate date]]; NSLog(@"%@", NSStringFromRange(range));
Given that this is a leap year, I’d expect “{1, 366}”. What do I get? “{1, 31}”. I suspect that wires got crossed with the days-in-a-month calculation. Oh, but the fun doesn’t stop there. Try this at home (kids: do not try this at home):
NSCalendarDate *date; int i; date = [NSCalendarDate dateWithYear:2007 month:1 day:1 hour:0 minute:0 second:0 timeZone:nil]; for (i = 0; i < 365; i++) { range = [[NSCalendar currentCalendar] rangeOfUnit:NSDayCalendarUnit inUnit:NSWeekCalendarUnit forDate:date]; if (range.length != 7) { NSLog(@"Date %@ has week of length %d", date, range.length); } date = [date dateByAddingYears:0 months:0 days:1 hours:0 minutes:0 seconds:0]; }
This cycles through each day of last year (2007) and calculates the number of days that week. Now, having just recently lived through that year, I think I can say with some certainty that no week had any more or less than 7 days in it. The above code prints out any oddballs. If you run it yourself, you find that quite a few days are in weeks exceeding 7 days. Upon closer inspection, you find that any day in a week straddling two months reports the number of days in the month, not the week. On weeks fully contained within a month, it reports 7 days. Again we are seeing a tendency towards the days-in-a-month calculation. Someone really likes computing that.
I've filed a bug report (rdar://5704940 for you Apple folks). Unless I'm doing something really wrong here or there were some time distortions last year that I was unaware of, you may want to avoid using NSCalendar's rangeOfUnit:inUnit:forDate:
on Leopard for the time being. Thinking about it though, the time distortions would explain a couple weekends…
Update (Feb. 1, 2008):
I strongly recommend reading the comments. Thanks to Chris Suter for explaining what Apple's logic is in doing it this way. We all thought he was nuts at first but it appears that he was just explaining Apple's craziness.
In short, things probably are working as designed by Apple. I still think the design is flawed and that it is horrendous API. It seems that, in Apple's mind, Leopard is correcting a bug in Tiger (in other words, the intuitive and useful behavior in Tiger was a bug).
The original point stands that one needs to take care with -rangeOfUnit:inUnit:forDate:
. It seems to be only useful for specific combinations of units and given the change in behavior between Tiger and Leopard, it becomes even more of a headache. If Apple is going to continue with this interpretation, then they should just treat these computations as undefined since the results are misleading.
Category: Cocoa, Debugging, OS X, Programming 17 comments »
January 25th, 2008 at 11:41 am
Those are exactly the kind of bugs that would have been caught with some good old-fashioned unit testing.
January 25th, 2008 at 1:34 pm
Thanks for the heads-up on that. I do use rangeInUnit:, and fortunately am not interested in days of a year (and there is a workaround by looping over months) not days in a week (no workaround there, it seems).
This is even more troubling when you use those APIs in a calendar-agnostic way, because it is hard to know what to expect for the japanese or bhuddist calendar, and you have to trust Apple on these. Which now I don’t feel like trusting too much.
January 25th, 2008 at 4:54 pm
Yeah, when it comes down to it, it’s a matter of trust. It’s hard to predict when it will throw out some incorrect value so I’m avoiding it altogether and using other methods of obtaining that info. Unfortunately for weeks, I couldn’t figure out a calendar-independent way of getting this so I’m assuming 7 days in a week for the time being. Angry Buddhists (which is an unlikely scenario) can email me to express their disappointment.
January 28th, 2008 at 6:52 pm
Time to bust out that copy of “Calendrical Calculations” and write your own replacement routine.
January 31st, 2008 at 5:53 pm
Are you sure you’re not misinterpreting what the function does?
If you say NSDayCalendarUnit is always the day within the month, then you’ll find that within a year it can take the range 1-31 which is what the function returns. You’re assuming that NSDayCalendarUnit can be the day within the year.
In the second example, you don’t tell us what the start day is, but I’ll bet that you’ll find that it varies and where a week straddles a month, you’re going to get a range 1-31, because the day unit can be, say, 28, 29, 30, 31, 1, 2, 3 and the only range that fits this is 1-31.
January 31st, 2008 at 8:21 pm
I don’t think I’m misinterpreting it. NSRange produces a start value and a LENGTH. Regardless of the week, the length should not waver between 7 and some number between 28 and 31. It would be a stretch of logic to think this is correct behavior.
Also, try my code on a Tiger machine. You’ll see that it reports the correct number of days in a year and every week has 7 days. I think that should be pretty good evidence that the Leopard version is broken and unacceptable.
January 31st, 2008 at 8:44 pm
Chris,
The problem is that usually the code above produces a result of 1-7, which would reflect days in a week, but that sometimes you get 1-31 or something suggesting days in a month. If it should be returning 1-31, it ought to do that all the time, but looking at the docs for rangeOfUnit:inUnit:forDate: it seems pretty clear that in the case above it should always return 1-7. However you interpret the docs, the results should be consistent, and they aren’t.
January 31st, 2008 at 9:39 pm
@mr_noodle: I think you’ll find is that it’s a bug in Tiger. On Tiger, you’ll find when it gets at the end of the month, it will report a range back of, say, { 28, 7 }, which suggests that the day can take the values, 28, 29, 30, 31, 32, 33, 34, which is clearly wrong.
@tom: The code above only produces a result of 1-7 if the first day happens to be the first day of the month. The reason the length differs is for the reason I outlined earlier: because the day wraps when a week straddles a month boundary. If you look at the week at the end of this month, you’ll find you get {1, 29} back.
January 31st, 2008 at 10:47 pm
I’m sorry but I’m not following your logic here. Why can’t the day occur on 28? It is calculating the calendar week (i.e. the week aligned with the calendar.) When I look at the last week in January, 2007, I see that the beginning of the week according to the Gregorian calendar (and assuming Sunday is the first day) falls on the 28th. If you check this under Tiger, you get {28, 7}. That means the calendar date starts on the 28th and lasts 7 days.
Leopard reports {1, 31}. Now, even if you take that 1 to be the first of the next month, 31 still doesn’t make sense under your logic because the next month is February and it only has 28 days. But all that is moot as the week does not start on the first for either Jan or Feb of 2007. In addition, back to the original bug I cited, there is no good reason why the week, which was accurately reported as being 7 days long in the other weeks (and in every week on Tiger) is reported as being 31 days. Of what possible use is that to the developer? It seems like you are trying to argue the “logic” of the bug. I just don’t see how you can argue this as being logical. It may be an explanation of why it’s happening, but that doesn’t mean it’s correct.
January 31st, 2008 at 11:26 pm
Firstly I should say that I’m in Sydney, so it’s February for me now. For the end of February you get { 1, 28 }. For January you get { 1, 31 }.
What the function does is ask this (in the case where you pass NSDayCalendarUnit and NSWeekCalendarUnit):
“What is the smallest range that will cover the possible values of day for the week on the date I specify?”
What the function does *not* do is tell you “what is the first day of that week and how long is it?”.
So, take 31 Jan 2008 for example. The week starts on 27 Jan, so the day can take the values, 27, 28, 29, 30, 31, 1, 2. The smallest range that covers those values is clearly {1, 31} which is what you get on Leopard.
I realise that you want the function to work differently, but I’m afraid it doesn’t and it’s perfectly logical the way it works. There is a bug on Tiger because by returning { 28, 7 } it’s suggesting that the day will always fall into that range i.e. 28-34.
I’ll bet your Mac Pro that Apple tell you it works as expected. 🙂
January 31st, 2008 at 11:47 pm
I’m sorry, but that logic is way to contrived. I’ve polled several devs and not one even intimated anything close to what you suggested, all expecting the same behavior I was. Plus, answer this, how is the behavior you described even remotely useful?
But, even using your logic, it doesn’t hold up. Try this:
date = [NSCalendarDate dateWithYear:2008 month:1 day:34 hour:0 minute:0 secon\
d:0 timeZone:nil];
NSLog(@"Date : %@", date);
34 is an acceptable value for day and NSCalendarDate is smart enough to know that extends into the next month. Plus, the range does not necessarily suggest that the numbers 32-34 are the actual “labels” for the days. The range says the 7 days starting from the 28th.
And additionally, your logic doesn’t explain the 31 days in a year. How is that supposed to be desired behavior?
Look, at this point, there is no way you can convince me that you’re way makes more sense or is in any way more useful. And if you can’t see the obvious logical expectation that everybody else has (ask around by just showing them the method description and see what they say) then there’s little point in me trying to convince you.
February 1st, 2008 at 12:15 am
Unfortunately I think I’m seeing Chris Suter’s point. I say “unfortunately” because it seems that the results are completely useless, and it makes me question whether NSCalendar is worth bothering with. I can follow the logic of why the results are as they are, what I can’t do is think of any reason it’s not stupid to design the API that way. Chris, can you suggest a situation where the results you describe would be a useful thing to get?
February 1st, 2008 at 12:39 am
Chris: I do see where you are coming from and how it does fit the docs. I apologize if it took me a while to figure out what you were getting at but you have to admit the logic of it is a bit twisted. Nonetheless, my point still stands that NSCalendar is rendered pretty useless by this. If it is by design, then I feel it is flawed by design as it ends up calculating a number that is not useful.
I’m still stuck hardcoding 7 days in a week. Chances are slim that this will come back to haunt me but it defeats the purpose of abstracting out the calendar if you can’t rely on it for information like this.
February 1st, 2008 at 12:51 am
> Plus, answer this, how is the behavior you described even remotely useful?
The only use I can think is for user interface validation/limitation. For example, let’s say you’ve specified a year and month, you could use this function to determine the range of values that day can take within that month. I can’t think of a use where the larger unit is NSCalendarUnitWeek though. Maybe it’s useful in non-Gregorian calendars.
If you want to work out the start date and length of a week, I’m sure you’ve probably spotted the Leopard only:
-[NSCalendar rangeOfUnit:startDate:interval:forDate:]
method. There’s also
-[NSCalendarDate years:months:days:hours:minutes:seconds:sinceDate:]
which might be useful for your purposes.
The example where you pass 34 for the day is clearly a special case and I certainly wouldn’t rely on it, especially since the documentation says that valid values are 1 through 31.
>And additionally, your logic doesn’t explain the 31 days in a year.
>How is that supposed to be desired behavior?
Within a particular year, the day can take the value 1 to 31. It’s not saying that there’s 31 days in a year.
> Look, at this point, there is no way you can convince me that you’re way
> makes more sense or is in any way more useful.
I’m not trying to convince you that it makes more sense or is any way more useful. I’m just explaining to you the way it is, or trying to at least.
> And if you can’t see the obvious logical expectation that everybody else has
> (ask around by just showing them the method description and see what they
> say) then there’s little point in me trying to convince you.
Of course I can see the logical expectation that everyone else has. What you expected and want is clearly more useful which is presumably why Apple added a method for that purpose.
February 1st, 2008 at 1:01 am
> I’m still stuck hardcoding 7 days in a week.
What about using:
-[NSCalendar maximumRangeOfUnit:]
with kCFCalendarUnitWeekday.
That’s 10.4.
February 1st, 2008 at 1:10 am
Point(s) taken. I know of the Leopard-only method but, it’s Leopard-only. Not a luxury we all have (at least not yet).
I think the range currently returned is just as inaccurate though. If you have (28, 29, 31, 1, 2, 3, 4), the range {1, 31} implies that 5, 6, 7..27 are in that range, which they aren’t. It’s the similar to the logic that {28, 7} implies numbers greater than 31.
In some ways, I think it might have been better if Apple treated this as an undefined computation and returned NSNotFound.
February 1st, 2008 at 11:09 am
Chris:
-maximumRangeOfUnit: seems to work. I seemed to have overlooked the NSWeekdayCalendarUnit since weekdays means the days Monday-Friday (at least in these parts) but in the APIs it represents a any day relative to a week. I’m not sure how big an assumption it is that all weeks are the same length for any particular calendar. It looks like the following would also work, and I assume should take into account any variances in week-length:
range = [[NSCalendar currentCalendar] rangeOfUnit:NSWeekdayCalendarUnit
inUnit:NSWeekCalendarUnit
forDate:date];
This will do what Tom was expecting. For a Gregorian calendar, it should always return {1, 7}. Still not useful for getting the starting day relative to the month. In such a case you can create an NSCalendarDate, get its -dayOfWeek and then create a new date by subtracting that number of days from the original date.
In any case, going to amend the article to tell people to read the comments.