显示标签为“Time”的博文。显示所有博文
显示标签为“Time”的博文。显示所有博文

2011年11月22日星期二

Working with Date and Time in Cocoa (Part 2)

Working with Date and Time in Cocoa (Part 2):
In part 2 of my little series on date and time handling in Cocoa I am going to talk about date parsing and formatting. In other words: how to convert strings into date objects and vice versa. You should read part 1 first if you haven’t yet to get an overview of the classes used by Cocoa’s date and time system.


NSDateFormatter



When working with date and time, two very common requirements are, (1) displaying dates in your UI, and (2) reading in date/time values from external sources like a web service or text file. Since humans are not very good at interpreting the second-based timestamps that NSDate uses to store dates internally, both of these tasks usually make it necessary to convert between NSDate and NSString or vice versa.


In the Foundation framework, the class to use for this task (in either direction) is NSDateFormatter. Let me show you how it works.


1. Turning Dates Into Strings



Let’s start with the easier (because less error-prone) of the two directions: turn an NSDate instance into a readable string. Usage of the NSDateFormatter class always involves three steps: (1) create the date formatter; (2) configure it; (3) send it a stringFromDate: message to get the result. Obviously, the configuration step is where the interesting stuff happens. We should differentiate between two separate use cases: do we want to create a human-readable output or do we need to create a string according to a specific format to be read by another API?


Formatting for Humans: Let the User Decide



When displaying dates in your app’s UI, you should always take the user’s preferences into account. Fortunately, that is easy with NSDateFormatter. To simply convert an NSDate into an NSString use code like this:


NSDate *myDate = [NSDate date];
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
[dateFormatter setDateStyle:NSDateFormatterMediumStyle];
[dateFormatter setTimeStyle:NSDateFormatterMediumStyle];
NSString *myDateString = [dateFormatter stringFromDate:myDate];
NSLog(@"%@", myDateString);


With my current locale settings (German), the output looks like this: 22.11.2011 18:33:19, but that’s just me. By default, NSDateFormatter observes the current user’s locale settings so other users might see results like Nov 22, 2011 6:33:19 PM or 2011-11-22 下午6:33:19 or even २२-११-२०११ ६:३३:१९ अपराह्, all for the same input and with the same code.


As a developer, you are not supposed to care about the actual output. Just use the setDateStyle: and setTimeStyle: methods to control how short or long the output should be. Possible values are NSDateFormatterShortStyle, NSDateFormatterMediumStyle, NSDateFormatterLongStyle and NSDateFormatterFullStyle; you can also use NSDateFormatterNoStyle to suppress the date or the time component in the resulting string.


The class method +localizedStringFromDate:dateStyle:timeStyle: provides a shorter way to achieve the same result as the code snippet above.


If you want to have more control over the output format, you can set a specific format using the setDateFormat: method. Note, though, that Apple specifically discourages that approach for human-readable dates since there is no date and time format that is accepted worldwide. NSDateFormatter understands the date format specifiers of the Unicode spec for date formats. If you want to go this route, have a look at the +dateFormatFromTemplate:options:locale: class method. It lets you specify a string of date format specifiers that your output string should include and returns an appropriate date format string for the specified locale.


Formatting for Machines: Controlled Environment Needed



It is a whole other matter if you need to create a date string according to the specification of a certain file format or API. In such a case, you usually have to follow a very strict spec to make sure the other party can read the string you are generating.1


It should be clear that we must use the setDateFormat: method here. But that is not enough. Remember from part 1 that you can represent the same point in time very differently, depending on the calendar and time zone. By default, NSDateFormatter uses the user’s current calendar and time zone, which are possibly different from the requirements. Most file formats and web APIs use the western, Gregorian calendar, so we need to make sure that our date formatter uses it, too:


NSDate *myDate = [NSDate dateWithTimeIntervalSinceReferenceDate:343675999.713839];
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
NSCalendar *calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
[dateFormatter setCalendar:calendar];


We must also make sure to set the date formatter’s locale to a generic value so as not to run into conflict’s with the user’s locale settings, which can influence the naming of weekdays and months as well as the clocks 12/24 hour setting. The date formatter’s setLocale: method expects an instance of the NSLocale class. To create one, we need to specify a locale identifiers. These usually consist of a combination of a language and a country code, such as @"en_US". For our needs, however, there exists the special locale identifier @"en_US_POSIX" that is guaranteed to not change in the future.


Note that a locale also includes a calendar setting so setting the calendar explicitly as we did above is no longer necessary (but does not hurt).


NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
[dateFormatter setLocale:locale];


The date’s time zone can possibly be included in the formatted output string. But as I also mentioned in part 1, time zone identifiers such as “+01:00”, “PST” or “CET” are notoriously ambiguous. In most cases, it’s best to stick with UTC:


NSTimeZone *timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0];
[dateFormatter setTimeZone:timeZone];


Now, we are finally ready to set our date format and create the result string. For example, to format a date according to the common RFC 3339 (ISO 8601) standard:


[dateFormatter setDateFormat:@"yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'"];
NSString *myDateString = [dateFormatter stringFromDate:myDate];
// => 2011-11-22T17:33:19Z


Again, see the Unicode standard mentioned above for a list of possible format specifiers. Pay special attention to the year format specifier @"yyyy". It is different than the capitalized @YYYY, which represents the year of the date’s week and not the year of the day. 99% of the time, you probably want to use @”yyyy”. I have seen this bug so many times in production code that it’s not funny anymore so make sure your unit tests catch it.2


Also note that I am using the literal character 'Z' to represent the UTC time zone we set on the date formatter before. If you need to include the time zone in your format string, make sure to experiment with the possible time zone format specifiers (z, Z, v, V, each with 1-4 characters) and different time zones to really understand what you’re getting yourself into.3 As I said, dealing with time zones is no fun, especially when it comes to ambiguous abbreviations or daylight savings time. It’s best to avoid if at all possible.


2. Turning Strings Into Dates



Let’s move on to the other side of NSDateFormatter: parsing a string representation of a date and/or time and converting it to an NSDate instance. Your main use case for this should be the parsing of dates you read in from a web service API or a text file.


Parsing Machine-Generated Dates



In this case, you use the class much like in the reverse case that we just discussed:



  1. Create an NSDateFormatter.

  2. Create a controlled environment by setting the formatter’s locale and possibly time zone as specified by the input format. In most cases, this means the en_US_POSIX locale and the UTC time zone.

  3. Set the formatter’s date format string to the specified format.

  4. Call dateFromString:.
For example, here is how to parse a date from an RSS feed entry of the form Mon, 06 Sep 2009 16:45:00 -0900 as specified in RFC 822:


NSString *myDateString = @"Mon, 06 Sep 2009 16:45:00 -0900";

NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
[dateFormatter setLocale:locale];
[dateFormatter setDateFormat:@"EEE, dd MMM yyyy HH:mm:ss Z"];

NSDate *myDate = [dateFormatter dateFromString:myDateString];
NSLog(@"%@", myDate);
// => 2009-09-07 01:45:00 +0000


Note how we did not set the date formatter’s time zone explicitly here. Instead, the Z character in the format string is now a format specifier for the time zone rather than the literal character it was in the example above. Also note that the output format of NSLog() shows date and time in UTC but it really represents the exact same point in time as the input string.


If a date formatter cannot parse the string, dateFromString: returns nil. Your code must be able to deal with this case gracefully.


Parsing Free-Form Date Strings



What if you don’t know the exact format of the string, e.g. because you want to let the user enter a date and time in a free-form text field4? I am afraid that NSDateFormatter will probably not be a big help then. The class does have a setLenient: method that enables heuristics when parsing a string. However, even in lenient mode you are still required to specify an exact date format. In lenient mode, the formatter correctly parses a date string containing slashes (@"03/11/2011 11:03:45") when the date format specifies blanks (@"dd MMM yyyy HH:mm:ss") but that seems approximately to be the extent of what it can do.


For really lenient parsing with NSDateFormatter, you would probably have to try multiple formats and check for success after each attempt. The Unicode standard includes some suggestions for lenient parsing if you want to go that route.


NSDataDetector to the Rescue!



A more promising approach might be the relatively new NSDataDetector class. Although not a classic member of the date and time handling classes in Cocoa, I want to mention it here for its ability to match, among other things, dates and times in free-form strings such as e-mail messages.


Because NSDataDetector is a special kind of regular expression, its API is completely different:


NSString *myDateString = @"24.11.2011 15:00";
NSError *error = nil;
NSDataDetector *detector = [NSDataDetector dataDetectorWithTypes:NSTextCheckingTypeDate error:&error];
NSArray *matches = [detector matchesInString:myDateString options:0 range:NSMakeRange(0, [myDateString length])];
for (NSTextCheckingResult *match in matches) {
   NSLog(@"Detected Date: %@", match.date);           // => 2011-11-24 14:00:00 +0000
   NSLog(@"Detected Time Zone: %@", match.timeZone);  // => (null)
   NSLog(@"Detected Duration: %f", match.duration);   // => 0.000000
}


In this case, the detection worked great5, and the detector can also deal with relative strings such as @"next Monday at 7 pm" or @"tomorrow at noon". NSDataDetector always seems to use the current locale and time zone to interpret dates in strings.


Miscellaneous Findings



Use Thread-Local Storage for NSDateFormatter



The -[NSDateFormatter init] method is quite expensive. If you need the same date formatter repeatedly, you should cache it, either in a static variable as in this example in Apple’s Technical Q&A QA1480 (see Listing 2) or, even better, by using Thread-Local Storage as explained by Alex Curylo in his article Threadsafe Date Formatting.


More Efficient Date Parsing



If you still encounter performance problems with NSDateFormatter, note this suggestion in the same QA1480:



Finally, if you’re willing to look at solutions outside of the Cocoa space, it’s very easy and efficient to parse and generate fixed-format dates using the standard C library functions strptime_l and strftime_l. Be aware that the C library also has the idea of a current locale. To guarantee a fixed date format, you should pass NULL to the loc parameter of these routines. This causes them to use the POSIX locale (also known as the C locale), which is equivalent to Cocoa’s “en_US_POSIX” locale.



For a data point, see Sam Soffes’s article how he improved the performance of his date parsing code by a factor of more than 20× by switching from NSDateFormatter to C-based date parsing.


GMT != UTC



Cédric Luthi discovered a seemingly weird NSDateFormatter behavior last weekend. See the following code:


NSString *dateString = @"0001-01-01 00:00:00 GMT";
NSDateFormatter *df = [[NSDateFormatter alloc] init];
[df setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
[df setDateFormat:@"yyyy-MM-dd HH:mm:ss zzz"];
NSDate *myDate = [df dateFromString: dateString];
NSLog(@"%@", myDate);


The result of the log statement: 0001-01-01 01:27:24 +0000. Hm, 01:27:24? Where can such a weird result come from? It turns out the answer is the time zone GMT. Do the same with UTC as the time zone and the result is the expected 0001-01-01 00:00:00 +0000.


So it seems that when dealing with historical dates, UTC and GMT are not identical in Cocoa. Instead, the system seems to use past definitions of GMT that were valid at the date in question. When I investigated this further, I found out that only for dates later than 9 April, 1968, GMT and UTC are identical in Cocoa. So beware of the difference if your app deals with the past. Use UTC as your time zone if you want to interpret all dates in today’s time system.





  1. Wouldn’t it be great if all web services used Unix timestamps to represent dates? I could omit this entire section as the conversion from and to NSDate would be trivial. For some reason, however, most APIs use string-based dates, which at least have the advantage of being human-readable.



  2. For example, the year-of-week for 1 January 2005 is 2004 because that date belongs to the last calendar week of 2004 rather than the first calendar week of 2005. Use NSDate *testDate = [NSDate dateWithTimeIntervalSinceReferenceDate:126273600.0] in your unit test and assert that you get the correct result for both format strings @"yyyy" and @"yyyy".



  3. By the way, CodeRunner, which I reviewed recently here on the blog is an awesome little app to experiment with date formatters. I used it constantly while writing this article.



  4. There are a number of apps that let you do just that, for example iCal in Lion, the great Fantastical app and Google Calendar.



  5. My time zone is one hour earlier than UTC, hence the time difference between input and output string.



2011年11月17日星期四

Working with Date and Time in Cocoa (Part 1)

Working with Date and Time in Cocoa (Part 1):

One of the most common problems I see newbies to Objective-C and Cocoa struggle with on Stack Overflow is how to deal correctly with dates and times. Cocoa’s approach to date and time handling may indeed seem overly complex at first glance: where other languages’ standard libraries seem to get by with just one or two classes to cover this field, the Foundation framework employs a staggering array of separate classes: NSDate, NSDateComponents, NSDateFormatter, NSCalendar, NSTimeZone. These classes deal directly with date and time and you should be familiar with all of them. In addition, you should also understand the role of the NSLocale class.



Let’s have a look at those classes one by one. As you will see, the Cocoa approach to date and time handling is not only quite easy to understand but also extremely flexible.



NSDate



NSDate is the central class of the date/time handling in Foundation, and at the same time the simplest imaginable. NSDate is nothing more than a wrapper around a single number: the number of seconds since 1 January, 2001, at 00:00 (midnight), UTC1. For values representing numbers of seconds, the framework uses a custom type, NSTimeInterval, which is currently defined as a 64-bit floating point value. According to the documentation, this is enough to yield an impressive sub-millisecond precision over a range of 10,000 years.



Represents an Absolute Point in Time



An NSDate object always represents an absolute point in time.2 This insight has two important consequences:




  1. There is no way to represent a certain date without including a specific time. For instance, to say that a particular NSDate instance represents 17 November 2011 makes no sense; you always have to include the particular time and time zone, such as 17 November 2011 00:00:00 +00:00 (or any other time of your choice).



    If your app needs to store dates with less-than-second precision in order to represent entire days, months or years, you should either not use your own custom class for this or, better, define a rule how your app deals with the unused components of the date (e.g., set the time components of the date to 00:00:00 +00:00).



    If you are sloppy and store dates with arbitrary time components, you will run into problems later when you want to compare or group multiple dates.



  2. NSDate has no concept of time zones. When it is midnight in London (17 November 2011 00:00:00 +00:00), it is only 6 pm on the day before in New York (16 November 2011 18:00:00 -06:00). Both dates represent the same point in time and are thus absolutely equal as far as NSDate is concerned.



    The implication of this is that you cannot store the time zone of a date and time in an NSDate object. If your app needs this information, you will have to store it in a different field. But more often than not, you will find that the time zone is actually not a field that should be stored with a date. Rather, it is a runtime preference of the person that is currently using your app, and your app should probably display most dates in the user’s current time zone.


How To Create An NSDate That Represents A Specific Date?



The easiest way to create an NSDate object is [NSDate date];. This will return an instance that represents the current moment and is often useful in code when it comes to storing creation or modification dates of records or to measure certain time intervals in your app.



The more generic task of creating an instance that represents a specific date and time turns out to be not so straightforward. There is the +dateWithTimeIntervalSinceReferenceDate: class method, but it requires you to know the interval in seconds between your desired date and the reference date (1 January 2001 00:00:00 +00:00). Turns out most people don’t count dates that way. That’s where the other classes come in.



NSCalendar



Most people reading this will probably only ever use the same single calendar with its 12 months named January, February and so on, seven-day weeks, counting the years from the reputed birth of Jesus. It is easy to forget that (1) the current “western” Gregorian Calendar has only been introduced in 1582 and (2) there are many more calendars in practical use around the world today. The Foundation framework can currently handle ten different calendars3.



It should be clear that, to specify a date unambiguously, we need to specify the calendar we use. For instance, while today’s date falls into the year 2011 in the familiar Gregorian calendar, the current year is 2554 and 5772 in the Buddhist and Hebrew calendars, respectively.



In Cocoa, a calendar is represented by the NSCalendar class. To create an instance of a specific calendar, pass one of the valid calendar identifiers to the initializer:



NSCalendar *gregorian = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSCalendar *buddhist = [[NSCalendar alloc] initWithCalendarIdentifier:NSBuddhistCalendar];
NSCalendar *hebrew = [[NSCalendar alloc] initWithCalendarIdentifier:NSHebrewCalendar];


There are also two class methods, +currentCalendar and +autoupdatingCurrentCalendar that return the current user’s preferred calendar. Note that the object returned by the latter method automatically adapts to changes in System Preferences.



The rest of the class is pretty straightforward. You can query the calendar for its configuration, i.e., things like the number of days that are in a month or which day is considered the first day of the week. Have a look at the documentation to get a feel for what is possible. There are also methods to split a date into its calendrical components or do the reverse but we are not quite ready to do that yet.



NSTimeZone



Any time specification is not precise enough without also indicating the time zone. I have already discussed that we need a way to reference time zones separately from NSDate and the NSTimeZone class does just that. There are several methods to create a time zone instance, the most straightforward being +timeZoneForSecondsFromGMT:.



Note, though, that the numeric offset from GMT is in many cases not enough to identify a specific time zone due to different daylight saving rules around the world. It is safer to specify a time zone by name using the +timeZoneWithName: method. Valid names are of the form @"Europe/Berlin".4



Another method, +timeZoneWithAbbreviation: should be handled with care. It is supposed to create time zones from common abbreviations such as “PST” or “CEST”. The problem is that these abbreviations are not always unique – different countries might use the same abbreviation for different time zones or different abbreviations for the same time zone. You should avoid this ambiguity if possible.



Last but not least, use the +systemTimeZone method to get a reference to the user’s current time zone.



NSDateComponents



We have almost everything we need now to manipulate dates in our code. Our fourth class, NSDateComponents, represents kind of the same information as NSDate: a single point in time. Unlike the latter, however, an NSDateComponents instance lets you access and manipulate every single calendrical component of that absolute point5, from the year down to the second and including such things as era, calendar, time zone and weekday.



Constructing Dates



Knowing this, let’s construct a date that represents the beginning of Steve Jobs’s Macworld 2007 keynote when he first introduced the iPhone:



NSCalendar *gregorian = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSTimeZone *pacificTime = [NSTimeZone timeZoneWithName:@"America/Los_Angeles"];

NSDateComponents *dateComps = [[NSDateComponents alloc] init];
[dateComps setCalendar:gregorian];
[dateComps setYear:2007];
[dateComps setMonth:1];
[dateComps setDay:9];
[dateComps setTimeZone:pacificTime];
[dateComps setHour:9]; // keynote started at 9:00 am
[dateComps setMinute:0]; // default value, can be omitted
[dateComps setSecond:0]; // default value, can be omitted

NSDate *dateOfKeynote = [dateComps date];
NSLog(@"Date of Keynote: %@", dateOfKeynote);


The output:




Date of Keynote: 2007-01-09 17:00:00 +0000


Hm, 17:00:00? But remember that NSDate does not care about time zones. When printing an NSDate with NSLog(), the system always uses UTC, which is 8 hours ahead of San Francisco (or 7 hours during daylight savings time). So the resulting date is indeed correct.



Now that we have an NSDateComponents instance, you would perhaps expect that you can get more information out of it. For example, let’s try to find out what day of the week the keynote was:



NSInteger weekday = [dateComps weekday]; // => -1 == NSUndefinedDateComponent


The documentation explains this:




An instance of NSDateComponents is not responsible for answering questions about a date beyond the information with which it was initialized. For example, if you initialize one with May 6, 2004, its weekday is NSUndefinedDateComponent, not Thursday. To get the correct day of the week, you must create a suitable instance of NSCalendar, create an NSDate object using dateFromComponents: and then use components:fromDate: to retrieve the weekday.




Let’s try that:



NSDate *dateOfKeynote = [dateComps date]; // or: [gregorian dateFromComponents:dateComps]
NSDateComponents *weekdayComponents = [gregorian components:NSWeekdayCalendarUnit fromDate:dateOfKeynote];
NSInteger weekday = [weekdayComponents weekday]; // => 3 == Tuesday


Note how we can specify in the -[NSCalendar components:fromDate:] method which date components we are interested in (using a bit mask). Some of the components can be expensive so it makes sense to only ask for the information you really need.



Date Calculations



The combination of NSDateComponents and NSCalendar is also the way to go for fancy date calculations. Say I want to create a date that goes back in time by exactly a month, a day and an hour from the current moment (using the current user’s calendar):



NSDate *now = [NSDate date];
NSDateComponents *comps = [[NSDateComponents alloc] init];
[comps setMonth:-1];
[comps setDay:-1];
[comps setHour:-1];
NSCalendar *calendar = [NSCalendar currentCalendar];
NSDate *newDate = [gregorian dateByAddingComponents:comps toDate:now options:0];


NSDateComponents is an incredibly flexible und useful class. In combination with NSCalendar, you can probably do all the date calculations you ever thought of.



Stay Tuned for Part 2: Date Parsing and Formatting



The classes I presented above give you a complete toolkit to work with date and time in your code. Two things are still missing, though: how to parse dates that come into your app as strings and how to output properly formatted dates as strings? Both of these tasks are handled by the NSDateFormatter class, which I will discuss in a separate post. Stay tuned.




  1. Or GMT, which is arguably the same, at least for our purposes.



  2. Yes, that means the date and time system does not deal with relativity. Cocoa is deeply rooted in Newtonian physics.



  3. With some limitations regarding the Chinese calendars. See the description of valid calendar identifiers in the documentation.



  4. Apple uses the well-known tz database. Log the result of +knownTimeZoneNames to get a list of all valid names.



  5. Implied in this is that NSDateComponents objects are mutable whereas NSDate instances are immutable.