Sneaky date formatters exposing more than you think

Ask any iOS developer about DateFormatter and one of the first things you will hear is that creating a DateFormatter instance is an expensive operation. The second thing you will hear is that after creating one you need to cache it.

In this post, I want to look at how expensive it is to create DateFormatter instances and how we can cache them effectively.

To determine just how expensive they are we will need to conduct a small experiment 🔬.

If you'd prefer not to have to manually build up the below examples, you can download the completed playground here.

The expense experiment

Our experiment will have two scenarios:

  1. Create a new instance of DateFormatter for each date conversion.
  2. Reuse the same instance of DateFormatter for all date conversions.
class DateConverter {

    let dateFormat = "y/MM/dd @ HH:mm"

    func convertDatesWithUniqueFormatter(_ dates: [Date]) {
        for date in dates {
            let dateFormatter = DateFormatter()
            dateFormatter.locale = Locale(identifier: "en_US_POSIX")
            dateFormatter.dateFormat = dateFormat

            _ = dateFormatter.string(from: date)
        }
    }

    func convertDatesWithReusedFormatter(_ dates: [Date]) {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = dateFormat

        for date in dates {
            _ = dateFormatter.string(from: date)
        }
    }
}

convertDatesWithUniqueFormatter represents the first scenario and convertDatesWithReusedFormatter the second scenario. Both methods follow a similar structure - loop over an array of dates and format each date into a string representation, the only difference is in how DateFormatter is used.

The XCTest framework makes it straight forward to measure the performance of our code using the appropriately named measure(_:) method. measure(_:) tracks the time in seconds that it takes for the code under test to finish executing. As it's possible for factors outside of our control to affect performance, measure(_:) executes the same test 10 times and reports the average time as its result ⏲️.

class DateConverterTests: XCTestCase {
    var sut: DateConverter!
    let dates = Array(repeating: Date(), count: 100)

    // MARK: - Lifecycle

    override func setUp() {
        super.setUp()
        sut = DateConverter()
    }

    override func tearDown() {
        sut = nil
        super.tearDown()
    }

    // MARK: - Tests

    func test_convertDatesWithUniqueFormatter_performance() {

        measure {
            sut.convertDatesWithUniqueFormatter(dates)
        }
    }

    func test_convertDatesWithReusedFormatter_performance() {

        measure {
            sut.convertDatesWithReusedFormatter(dates)
        }
    }
}

Running the two above tests results in generating the following console output:

Of course running the tests on your computer will give you different results.

The above results show that when converting 100 dates, reusing a DateFormatter instance takes on average 20% (0.02 vs 0.004 seconds) of the time that creating a unique DateFormatter instance for each conversion does. While in real terms the difference in time isn't much, as a percentage the difference is pretty conclusive - reusing a DateFormatter instance is 5x cheaper. If we are thinking in terms of UX, the additional overhead from always creating a new instance of DateFomatter could be the difference between a smooth scrolling table view and a jumpy one.

Premature optimisation is the mother of many bugs so before deciding to improve the performance of any one area, it's important to make sure that that area is actually a performance bottleneck - Instruments and Xcode itself are great tools for profiling. By only optimising actual performance bottlenecks we can ensure that we are not wasting time and not unnecessarily making our codebase more complex than it needs to be.

Working with performant DateFormatters

Now that it's been determined that reusing a DateFormatter instance offers a performance improvement and we've identified that this performance improvement will lead to a better user experience, the question is:

"How do we reuse it?"

It's simple enough to extract the DateFormatter instance into a local variable or private property so it can be reused and I won't explore these here - things really start to get interesting when the same formatter is needed across the project.

While normally derided as an overused pattern, I think sharing formatters would be greatly served by using the Singleton pattern:

class DateFormattingHelper {

    // MARK: - Shared

    static let shared = DateFormattingHelper()

    // MARK: - Formatters

    let dobDateFormatter: DateFormatter = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "y/MM/dd @ HH:mm"

        return dateFormatter
    }()
}

If we then wanted to format Date instances into date-of-birth strings, we could add a method similar to:

func formatDOB(_ date: Date, with dateFormatter: DateFormatter) -> String {
    let formattedDate = dateFormatter.string(from: date)
    return ("Date of birth: \(formattedDate)")
}

And when it's called, pass in the shared DateFormatter instance that is a property on DateFormattingHelper singleton like:

let dateFormatter = DateFormattingHelper.shared.dobDateFormatter
let dobFormattedString = formatDOB(date, with: dateFormatter)

It's a simple, easy to read and easy to test approach that ensures that DateFormatter instances are being reused. It's not too hard to imagine adding more DateFormatter properties to DateFormattingHelper and more helper methods like formatDOB to our view controllers to meet all of our date formatting needs.

But before we get too carried away with our success, let's examine our use of the Singleton pattern here in more detail. A key design consideration when using any pattern to expose shared state is: mutability. Ideally shared state should be immutable to prevent a situation where different parts of the codebase can change that state and so indirectly affect other parts that depend on that shared state. However, as we have seen DateFormattingHelper only has one property and that property is a constant (let) so mutability shouldn't be an issue here 👏.

Or is it? 🤔

Swift has two categories of Type:

  1. Value - structs, enums, or tuples
  2. Reference - classes, functions or closures

The main difference between the types is shown when they are used in an assignment.

When a value-type instance is assigned, an exact copy of that instance is made and it is this copy that is then assigned. This results in two instances of that value-type existing, with both instances (at least initially) having the same property values:

struct PersonValueTypeExample: CustomStringConvertible {
    var name: String
    var age: Int

    var description: String {
        return ("name: \(name), age: \(age)")
    }
}

var a = PersonValueTypeExample(name: "Susie", age: 29)
var b = a
b.name = "Samantha"
a.age = 56

print("a: \(a)") // prints "a: name: Susie, age: 56"
print("b: \(b)") // prints "b: name: Samantha, age: 29"

CustomStringConvertible is used here only to override the description property and ensure a consistent printout format between the value-type and reference-type examples.

As the above example shows, when a is assigned to b, a copy of a is made and assigned to b. Any change made to either a or b will be limited to that instance only - this can be seen in the description string printed for both.

Copying a value-type instance can be an expensive process so a number of techniques are used to try and avoid making the copy. These techniques range from compiler optimisations to implementation details in the struct itself such as Copy-On-Write. These optimisation techniques can result in our value-type instances behaving more like reference-types under the hood. While this is useful to know about, the important thing to note is that these optimisations are transparent to us as users of these value-types. So we can build a system which is dependent upon the difference between value and reference types without having to care how the instances of these types are actually stored in memory and accessed.

When a reference-type instance is assigned, a reference (or pointer) to that instance is created and it is this reference that is then actually assigned (rather than the instance itself). This can result in an instance having multiple references so that the instance becomes an informal shared instance to those references:

class PersonReferenceTypeExample: CustomStringConvertible {
    var name: String
    var age: Int

    var description: String {
    return ("name: \(name), age: \(age)")
    }

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

let c = PersonReferenceTypeExample(name: "Susie", age: 29)
var d = c
d.name = "Samantha"
c.age = 56

print("c: \(c)") // prints "c: name: Samantha, age: 56"
print("d: \(d)") // prints "d: name: Samantha, age: 56"

As the above example shows when c is assigned to d, a new reference to the same instance that c is pointing at is created and assigned to d. Any change made to either c or d will affect the other - this can be seen in the description string printed for both.

The sharper eyed among you may have spotted that the above examples differ in one additional aspect other than just class/struct and the naming of the variables - in the struct example a is defined as a var whereas in the class example c is a let. When a value-type instance is assigned to a constant, all of its properties are treated as constant properties (and any mutating methods are no longer available) - this isn't true for reference-type instances where var and let are always respected.

You may have heard of pass-by-reference and pass-by-value and currently be thinking that these terms are connected to the above types. This is a common misunderstanding, by default Swift uses pass-by-value when passing both value-type and reference-type instances. The only difference as we have seen is what is copied - with a value-type it's the entire instance, with a reference-type instance it's a reference/pointer. Why this is the case took me a while to fully get my head around, I found the following Stack Overflow question very informative, as well as the wiki page on Evaluation Strategy especially the "Call by sharing" section which I think best describes what happens when we pass a reference-type into a method and why that isn't pass-by-reference. Using the linked wiki definition of pass-by-reference, the nearest that Swift gets to it is using the inout keyword.

While an interesting detour, you may be thinking:

"What does any of it have to do with DateFormattingHelper?"

Well, dobDateFormatter is a reference-type so when passing it around, new references are being made that point to the same shared DateFomatter instance. On first glance that's fine because dobDateFormatter is a constant property and so is immutable however the trouble lays in that some of the properties on DateFormatter are mutable. As we seen above (unlike a value-type instance) a reference-type instance's property declarations are not affected by how that instance was defined. So while it looks like in DateFormattingHelper we have immutable shared state we actually have mutable shared state - just hidden one layer deeper.

A bug that is introduced by changing shared state is often discovered not where the shared state is changed but elsewhere in the codebase where the previous state of the shared state was expected - think about what the consequences of changing the badgeFormat value on dobDateFormatter would be. Even then the other part of the app will only surface the bug if the code that introduces the bug is called first i.e. the bug is dependent on how the user moves through the app 😱. A nightmare of a bug that can take a long time to track down and solve. We can avoid the many sleepless nights this bug would cause by turning hidden mutable properties into immutable properties.

Going from mutable to immutable is actually surprisingly straightforward in this example, we just need to hide our DateFomatter instances behind a protocol. When it comes to using the dobDateFormatter property above from outside the DateFormattingHelper class we are actually only interested in using a DateFomatter to convert a Date into a String so we can produce an extremely limited protocol:

protocol DateFormatterType {
    func string(from date: Date) -> String
}

We just need to conform DateFomatter to our custom DateFormatterType using an extension:

extension DateFormatter: DateFormatterType { }

Now that we have this protocol we can return DateFomatter instances wrapped up as DateFormatterType instance:

let dobDateFormatter: DateFormatterType = {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "en_US_POSIX")
    dateFormatter.dateFormat = "y/MM/dd @ HH:mm"

    return dateFormatter
}()

So whenever we use DateFomatter we would use DateFormatterType instead so:

func formatDOB(_ date: Date, with dateFormatter: DateFormatterType) -> String {
    let formattedDate = dateFormatter.string(from: date)
    return ("Date of birth: \(formattedDate)")
}

In fact we can take this even further by absorbing the formatDOB method into DateFormattingHelper and hide DateFormatterType altogether:

private protocol DateFormatterType {
    func string(from date: Date) -> String
}

class DateFormattingHelper {

    // MARK: - Shared

    static let shared = DateFormattingHelper()

    // MARK: - Formatters

    private let dobDateFormatter: DateFormatterType = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "y/MM/dd @ HH:mm"

        return dateFormatter
    }()

    // MARK: - DOB

    func formatDOBDate(_ date: Date) -> String {
        let formattedDate = dobDateFormatter.string(from: date)
        return ("Date of birth: \(formattedDate)")
    }
}

DateFormatterType and dobDateFormatter are now private and so only accessible from inside DateFormattingHelper. At this point, you may be thinking:

"What's the point of still having the DateFormatterType protocol?"

I think it's important to understand that the issue that DateFormatterType is solving isn't one of malicious intent from someone deliberately trying to crash the app but from a trusted colleague who through either clumsiness or a lack of domain knowledge, accidentally misuses a DateFormatter instance. So if we simply made the dobDateFormatter property private, it would still be possible to introduce this type of bug into DateFormattingHelper (by modifying the properties of e.g. dobDateFormatter from inside formatDOBDate) however by continuing to use the protocol approach we can actually reduce this risk further. So by being both private and hidden behind a protocol, we are protecting against unintended mutations of a shared instance both external and internal to DateFormattingHelper.

Going a step beyond

Now that we have the basic mechanisms in place, lets look at a more complete version of DateFormattingHelper:

class DateFormattingHelper {

    // MARK: - Shared

    static let shared = DateFormattingHelper()

    // MARK: - Formatters

    private let dobDateFormatter: DateFormatterType = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "y/MM/dd @ HH:mm"

        return dateFormatter
    }()

    private let dayMonthTimeDateFormatter: DateFormatterType = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "dd MMM @ HH:mm"

        return dateFormatter
    }()

    private let hourMinuteDateFormatter: DateFormatterType = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "HH:mm"

        return dateFormatter
    }()

    private let dayMonthYearDateFormatter: DateFormatterType = {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "d MMM 'of' y"

        return dateFormatter
    }()

    // MARK: - DOB

    func formatDOBDate(_ date: Date) -> String {
        let formattedDate = dobDateFormatter.string(from: date)
        return ("Date of birth: \(formattedDate)")
    }

    // MARK: - Account

    func formatLastActiveDate(_ date: Date, now: Date = Date()) -> String {
        let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!

        var dateFormatter = dayMonthTimeDateFormatter
        if date > yesterday {
            dateFormatter = hourMinuteDateFormatter
        }

        let formattedDate = dateFormatter.string(from: date)
        return ("Last active: \(formattedDate)")
    }

    // MARK: - Post

    func formatPostCreatedDate(_ date: Date) -> String {
        let formattedDate = dayMonthYearDateFormatter.string(from: date)
        return formattedDate
    }

    // MARK: - Commenting

    func formatCommentedDate(_ date: Date) -> String {
        let formattedDate = dayMonthTimeDateFormatter.string(from: date)
        return ("Comment posted: \(formattedDate)")
    }
}

This gives a better idea of what DateFormattingHelper can become. However, you can quickly see how just adding three more properties has bloated this class. There is room for further optimisation here.

In the above example the only configuration difference between the DateFomatter instances is the dateFormat value. This shared configuration can be used to our advantage to remove the distinct DateFormatter properties:

class CachedDateFormattingHelper {

    // MARK: - Shared

    static let shared = CachedDateFormattingHelper()
    
    // MARK: - Queue
    
    let cachedDateFormattersQueue = DispatchQueue(label: "com.boles.date.formatter.queue")

    // MARK: - Cached Formatters

    private var cachedDateFormatters = [String : DateFormatterType]()

    private func cachedDateFormatter(withFormat format: String) -> DateFormatterType {
        return cachedDateFormattersQueue.sync {
            let key = format
            if let cachedFormatter = cachedDateFormatters[key] {
                return cachedFormatter
            }
            
            let dateFormatter = DateFormatter()
            dateFormatter.locale = Locale(identifier: "en_US_POSIX")
            dateFormatter.dateFormat = format
            
            cachedDateFormatters[key] = dateFormatter
            
            return dateFormatter
        }
    }

    // MARK: - DOB

    func formatDOBDate(_ date: Date) -> String {
        let dateFormatter = cachedDateFormatter(withFormat: "y/MM/dd @ HH:mm")
        let formattedDate = dateFormatter.string(from: date)
        return ("Date of birth: \(formattedDate)")
    }

    // MARK: - Account

    func formatLastActiveDate(_ date: Date, now: Date = Date()) -> String {
        let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: now)!

        var dateFormatter = cachedDateFormatter(withFormat: "dd MMM @ HH:mm")
        if date > yesterday {
            dateFormatter = cachedDateFormatter(withFormat: "HH:mm")
        }

        let formattedDate = dateFormatter.string(from: date)
        return ("Last active: \(formattedDate)")
    }

    // MARK: - Post

    func formatPostCreatedDate(_ date: Date) -> String {
        let dateFormatter = cachedDateFormatter(withFormat: "d MMM 'of' y")
        let formattedDate = dateFormatter.string(from: date)
        return formattedDate
    }

    // MARK: - Commenting

    func formatCommentedDate(_ date: Date) -> String {
        let dateFormatter = cachedDateFormatter(withFormat: "dd MMM @ HH:mm")
        let formattedDate = dateFormatter.string(from: date)
        return ("Comment posted: \(formattedDate)")
    }
}

Above we have added a private dictionary (cachedDateFormatters) where all the DateFormatter instances are stored and an accessor method (cachedDateFormatter) to determine if a new instance of DateFormatter needs to be created or not based on if it exists in that dictionary, with the dateFormat value itself acting as the key.

Setting or getting a cached DateFormatter instance has been wrapped in a sync operation on its own queue to make using cachedDateFormatter thread safe - effectively, the cachedDateFormattersQueue queue here is acting as lock.

Credit for the above dictionary approach must be given to Jordon Smith, who describes it in this post.

Of course, if further customisation is needed in each of the formatters this approach isn't suitable, however for this example I think it offers a very elegant solution.

In the completed playground accompanying this post, I've added the same tests to CachedDateFormattingHelper that I use for DateFormattingHelper to show that externally both implementations behave the same way.

Looking back at those dates

In this post we questioned the common belief that creating DateFormatter instances is expensive (yes they are), looked at ways that we could avoid paying too much for them (local variables, properties and singletons), delved deep on why exposing shared DateFormatter instances was a bad idea (unexpected shared mutable state), how we could avoid that shared mutable state (helper methods and an immutable protocol) and even how we can take caching DateFormatter instances a step beyond (hidden dictionary). Overall we covered a lot of ground and if you've made it this far (as you have) I believe you deserve whatever you've been denying yourself all day 😉.

For me it's a lovely hot chocolate with marshmallows ☕ - I can guarantee no unexpected shared state there!

Special thanks to Paul Pires for helping to come up with this idea of how to share DateFormatter instances.