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:
- Create a new instance of
DateFormatter
for each date conversion. - 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
andXcode
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:
- Value -
structs
,enums
, ortuples
- Reference -
classes
,functions
orclosures
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 thedescription
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 forDateFormattingHelper
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.