Search code examples
iosobjective-cnscalendar

Proper NSCalendar initialization


Our app works quite a bit with dates, but at this time we only support the Gregorian calendar and an app wide NSCalendar instance is initialized as follows:

NSCalendar *appCalendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian];

The docs of the above method states that "The returned calendar defaults to the current locale and default time zone." However, when running the app on a device with the region set to "United Kingdom", calling [appCalendar firstWeekday] returned a value of 1 (Sunday) rather than the expected 2 (Monday). If I run [[NSCalendar currentCalendar] firstWeekday], the correct value of 2 is returned. At first I thought that a locale may not be set on "appCalendar", but logging revealed it had one, though it lacked a countrycode etc., which the "currentCalendar" instance does have and which allows it to return the correct firstWeekDay.

Should a locale explicitly be set on the object returned from calendarWithIdentifier and if so, are there any considerations in doing so?

Update

Based on zrzka's answer below, I recommend that a locale is explicitly set when initializing a calendar with an identifier e.g.

NSCalendar *appCalendar = [NSCalendar calendarWithIdentifier:NSCalendarIdentifierGregorian];

appCalendar.locale = [NSLocale currentLocale];

Solution

  • The documentation is wrong:

    The returned calendar defaults to the current locale and default time zone.

    It should be:

    The returned calendar defaults to the system locale and default time zone.

    CFCalendar.c:

    CFCalendarRef CFCalendarCreateWithIdentifier(CFAllocatorRef allocator, CFStringRef identifier) {
        if (allocator == NULL) allocator = __CFGetDefaultAllocator();
        __CFGenericValidateType(allocator, CFAllocatorGetTypeID());
        __CFGenericValidateType(identifier, CFStringGetTypeID());
        // return NULL until Chinese calendar is available
        if (identifier != kCFGregorianCalendar && identifier != kCFBuddhistCalendar && identifier != kCFJapaneseCalendar && identifier != kCFIslamicCalendar && identifier != kCFIslamicCivilCalendar && identifier != kCFHebrewCalendar) {
    //    if (identifier != kCFGregorianCalendar && identifier != kCFBuddhistCalendar && identifier != kCFJapaneseCalendar && identifier != kCFIslamicCalendar && identifier != kCFIslamicCivilCalendar && identifier != kCFHebrewCalendar && identifier != kCFChineseCalendar) {
        if (CFEqual(kCFGregorianCalendar, identifier)) identifier = kCFGregorianCalendar;
        else if (CFEqual(kCFBuddhistCalendar, identifier)) identifier = kCFBuddhistCalendar;
        else if (CFEqual(kCFJapaneseCalendar, identifier)) identifier = kCFJapaneseCalendar;
        else if (CFEqual(kCFIslamicCalendar, identifier)) identifier = kCFIslamicCalendar;
        else if (CFEqual(kCFIslamicCivilCalendar, identifier)) identifier = kCFIslamicCivilCalendar;
        else if (CFEqual(kCFHebrewCalendar, identifier)) identifier = kCFHebrewCalendar;
    //  else if (CFEqual(kCFChineseCalendar, identifier)) identifier = kCFChineseCalendar;
        else return NULL;
        }
        struct __CFCalendar *calendar = NULL;
        uint32_t size = sizeof(struct __CFCalendar) - sizeof(CFRuntimeBase);
        calendar = (struct __CFCalendar *)_CFRuntimeCreateInstance(allocator, CFCalendarGetTypeID(), size, NULL);
        if (NULL == calendar) {
        return NULL;
        }
        calendar->_identifier = (CFStringRef)CFRetain(identifier);
        calendar->_locale = NULL;
        calendar->_localeID = CFLocaleGetIdentifier(CFLocaleGetSystem());
        calendar->_tz = CFTimeZoneCopyDefault();
        calendar->_cal = NULL;
        return (CFCalendarRef)calendar;
    }
    

    _locale is initialized with NULL and _localeID is initialized with locale identifier of the system locale (which is an empty string on iPhone & simulator). _cal is set to NULL.

    CFIndex CFCalendarGetFirstWeekday(CFCalendarRef calendar) {
        CF_OBJC_FUNCDISPATCHV(CFCalendarGetTypeID(), CFIndex, calendar, firstWeekday);
        __CFGenericValidateType(calendar, CFCalendarGetTypeID());
        if (!calendar->_cal) __CFCalendarSetupCal(calendar);
        if (calendar->_cal) {
        return ucal_getAttribute(calendar->_cal, UCAL_FIRST_DAY_OF_WEEK);
        }
        return -1;
    }
    

    So, because _cal is NULL, __CFCalendarSetupCal is called.

    static void __CFCalendarSetupCal(CFCalendarRef calendar) {
        calendar->_cal = __CFCalendarCreateUCalendar(calendar->_identifier, calendar->_localeID, calendar->_tz);
    }
    

    Which calls __CFCalendarCreateUCalendar with _localeID which is an empty string.

    I can confirm this behavior on iOS 11, 12 & 13. The source code is for something called CF-Lite, but I went further and disassembled actual CoreFoundation framework and it does the same thing ...

    call       _CFLocaleGetSystem             ; _CFLocaleGetSystem
    mov        rdi, rax                       ; argument "cf" for method _CFRetain
    call       _CFRetain                      ; _CFRetain
    mov        qword [r15+0x18], rax
    call       _CFTimeZoneCopyDefault         ; _CFTimeZoneCopyDefault
    mov        qword [r15+0x20], rax
    mov        rbx, qword [r15+0x10]
    mov        rdi, qword [r15+0x18]          ; argument "locale" for method _CFLocaleGetIdentifier
    call       _CFLocaleGetIdentifier         ; _CFLocaleGetIdentifier
    mov        rdx, qword [r15+0x20]          ; argument #3 for method ___CFCalendarCreateUCalendar
    mov        rdi, rbx                       ; argument #1 for method ___CFCalendarCreateUCalendar
    mov        rsi, rax                       ; argument #2 for method ___CFCalendarCreateUCalendar
    call       ___CFCalendarCreateUCalendar   ; ___CFCalendarCreateUCalendar
    

    ... using an empty identifier from CFLocaleGetIdentifier from CFLocaleGetSystem.

    When you check the CFCalendarCreateWithIdentifier documentation, there's not a word about current locale, time zone, ...

    What's even more interesting is the difference (section Discussion) for these two methods:

    But there's no difference, calendarWithIdentifier: just calls the alloc & initWithCalendarIdentifier:.

    push       rbp
    mov        rbp, rsp
    push       r14
    push       rbx
    mov        rbx, rdx
    mov        rsi, qword [0x3cb478]                       ; argument "selector" for method _objc_msgSend, @selector(alloc)
    mov        r14, qword [_objc_msgSend_390220]           ; _objc_msgSend_390220
    call       r14                                         ; Jumps to 0x553ae0 (_objc_msgSend), _objc_msgSend
    mov        rsi, qword [0x3cc768]                       ; argument "selector" for method _objc_msgSend, @selector(initWithCalendarIdentifier:)
    ...
    

    I believe it's a documentation problem which should be reported to Apple (did it, FB7740798).